NIFIREG-313 - Add OpenId Connect support for authenticating users (#296)
- Adding the base files for OIDC from NiFi.
- Added Login and Logout filters. Added unit tests.
- Added changes from NiFi PR #4344. Fixing tests.
- alopresto provided a patch to fix failing tests as a result of the NiFiRegistryProperties mocking not working the same as NiFi and other minor issues.
- Added the OIDC identity provider to the security config.
- Testing javascript to make call to exchange OIDC cookie for JWT. I believe some parts in the NiFiRegistrySecurityConfig are not required, similar to how Kerberos auth works.
- OIDC Authentication works correctly. Need to adjust javascript to allow either Kerberos or OIDC - currently only allows OIDC.
- Updated ticketExchange function to call both Kerberos and OIDC endpoints to retrieve a JWT.
- User can hit the LOGIN button to log in with OIDC, or it will redirect to the default login dialog for basic credential login.
- Removing unused imports
- OIDC login can only be attempted if OIDC is configured.
- Fixed ticketExchange() javascript to work with x509 certs. Removed unnecessary code from LogoutFilter.
- Removed unnecessary comments
- Fix codestyle issue
- Checkstyle fix.
- Checkstyle fix.
- Cleaned up some unnecessary files. Fixed OIDC logout in javascript and serverside. /oidc/logout now removes JWT serverside.
- Fixed codestyle issues.
- Added OIDC properties to nifi-registry.properties file.
- Updated NOTICE file with new dependency details.
diff --git a/nifi-registry-assembly/NOTICE b/nifi-registry-assembly/NOTICE
index 6e55efa..4e77c5c 100644
--- a/nifi-registry-assembly/NOTICE
+++ b/nifi-registry-assembly/NOTICE
@@ -250,6 +250,15 @@
The following NOTICE information applies:
Copyright 2017 SmartBear Software
+ (ASLv2) Nimbus OAuth 2.0 SDK with OpenID Connect extensions
+ The following NOTICE information applies:
+ Nimbus OAuth 2.0 SDK with OpenID Connect extensions
+ Copyright 2012-2020, Connect2id Ltd and contributors.
+
+ (ASLv2) Guava
+ The following NOTICE information applies:
+ Guava
+ Copyright 2015 The Guava Authors
************************
Common Development and Distribution License 1.1
diff --git a/nifi-registry-assembly/pom.xml b/nifi-registry-assembly/pom.xml
index 26fe9d6..ff7742f 100644
--- a/nifi-registry-assembly/pom.xml
+++ b/nifi-registry-assembly/pom.xml
@@ -197,6 +197,14 @@
<nifi.registry.kerberos.spnego.keytab.location />
<nifi.registry.kerberos.spnego.authentication.expiration>12 hours</nifi.registry.kerberos.spnego.authentication.expiration>
+ <!-- nifi-registry.properties: OIDC properties -->
+ <nifi.registry.security.user.oidc.discovery.url />
+ <nifi.registry.security.user.oidc.connect.timeout />
+ <nifi.registry.security.user.oidc.read.timeout />
+ <nifi.registry.security.user.oidc.client.id />
+ <nifi.registry.security.user.oidc.client.secret />
+ <nifi.registry.security.user.oidc.preferred.jwsalgorithm />
+
<!-- nifi.registry.properties: revision management properties -->
<nifi.registry.revisions.enabled>false</nifi.registry.revisions.enabled>
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/authorization/CurrentUser.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/authorization/CurrentUser.java
index af135ec..3bbf8e5 100644
--- a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/authorization/CurrentUser.java
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/authorization/CurrentUser.java
@@ -25,6 +25,7 @@
private String identity;
private boolean anonymous;
private boolean loginSupported;
+ private boolean oidcLoginSupported;
private ResourcePermissions resourcePermissions;
@ApiModelProperty(value = "The identity of the current user", readOnly = true)
@@ -45,15 +46,24 @@
this.anonymous = anonymous;
}
- @ApiModelProperty(value = "Indicates if the NiFi instance supports logging in")
+ @ApiModelProperty(value = "Indicates if the NiFi Registry instance supports logging in")
public boolean isLoginSupported() {
return loginSupported;
}
+ @ApiModelProperty(value = "Indicates if the NiFi Registry instance supports logging in with an OIDC provider")
+ public boolean isOIDCLoginSupported() {
+ return oidcLoginSupported;
+ }
+
public void setLoginSupported(boolean loginSupported) {
this.loginSupported = loginSupported;
}
+ public void setOIDCLoginSupported(boolean oidcLoginSupported) {
+ this.oidcLoginSupported = oidcLoginSupported;
+ }
+
@ApiModelProperty(value = "The access that the current user has to top level resources", readOnly = true)
public ResourcePermissions getResourcePermissions() {
return resourcePermissions;
diff --git a/nifi-registry-core/nifi-registry-jetty/src/main/java/org/apache/nifi/registry/jetty/JettyServer.java b/nifi-registry-core/nifi-registry-jetty/src/main/java/org/apache/nifi/registry/jetty/JettyServer.java
index 1b15f07..04d08de 100644
--- a/nifi-registry-core/nifi-registry-jetty/src/main/java/org/apache/nifi/registry/jetty/JettyServer.java
+++ b/nifi-registry-core/nifi-registry-jetty/src/main/java/org/apache/nifi/registry/jetty/JettyServer.java
@@ -292,6 +292,7 @@
}
webUiContext = loadWar(webUiWar, "/nifi-registry");
+ webUiContext.getInitParams().put("oidc-supported", String.valueOf(properties.isOidcEnabled()));
webApiContext = loadWar(webApiWar, "/nifi-registry-api", getWebApiAdditionalClasspath());
logger.info("Adding {} object to ServletContext with key 'nifi-registry.properties'", properties.getClass().getSimpleName());
diff --git a/nifi-registry-core/nifi-registry-properties/pom.xml b/nifi-registry-core/nifi-registry-properties/pom.xml
index 598c623..ac6913d 100644
--- a/nifi-registry-core/nifi-registry-properties/pom.xml
+++ b/nifi-registry-core/nifi-registry-properties/pom.xml
@@ -68,5 +68,9 @@
<version>1.7.12</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ </dependency>
</dependencies>
</project>
diff --git a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
index 4700383..48b90e5 100644
--- a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
+++ b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
@@ -21,10 +21,15 @@
import org.slf4j.LoggerFactory;
import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Properties;
import java.util.Set;
+import java.util.stream.Collectors;
public class NiFiRegistryProperties extends Properties {
@@ -87,6 +92,16 @@
public static final String KERBEROS_SERVICE_PRINCIPAL = "nifi.registry.kerberos.service.principal";
public static final String KERBEROS_SERVICE_KEYTAB_LOCATION = "nifi.registry.kerberos.service.keytab.location";
+ // OIDC properties
+ public static final String SECURITY_USER_OIDC_DISCOVERY_URL = "nifi.registry.security.user.oidc.discovery.url";
+ public static final String SECURITY_USER_OIDC_CONNECT_TIMEOUT = "nifi.registry.security.user.oidc.connect.timeout";
+ public static final String SECURITY_USER_OIDC_READ_TIMEOUT = "nifi.registry.security.user.oidc.read.timeout";
+ public static final String SECURITY_USER_OIDC_CLIENT_ID = "nifi.registry.security.user.oidc.client.id";
+ public static final String SECURITY_USER_OIDC_CLIENT_SECRET = "nifi.registry.security.user.oidc.client.secret";
+ public static final String SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM = "nifi.registry.security.user.oidc.preferred.jwsalgorithm";
+ public static final String SECURITY_USER_OIDC_ADDITIONAL_SCOPES = "nifi.registry.security.user.oidc.additional.scopes";
+ public static final String SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER = "nifi.registry.security.user.oidc.claim.identifying.user";
+
// Revision Management Properties
public static final String REVISIONS_ENABLED = "nifi.registry.revisions.enabled";
@@ -100,6 +115,16 @@
public static final String DEFAULT_AUTHENTICATION_EXPIRATION = "12 hours";
public static final String DEFAULT_EXTENSIONS_WORKING_DIR = "./work/extensions";
public static final String DEFAULT_WEB_SHOULD_SEND_SERVER_VERSION = "true";
+ public static final String DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT = "5 secs";
+ public static final String DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT = "5 secs";
+
+ public NiFiRegistryProperties() {
+ super();
+ }
+
+ public NiFiRegistryProperties(Map<String, String> props) {
+ this.putAll(props);
+ }
public int getWebThreads() {
int webThreads = 200;
@@ -336,4 +361,101 @@
}
}
+ /**
+ * Returns true if the login identity provider has been configured.
+ *
+ * @return true if the login identity provider has been configured
+ */
+ public boolean isLoginIdentityProviderEnabled() {
+ return !StringUtils.isBlank(getProperty(NiFiRegistryProperties.SECURITY_IDENTITY_PROVIDER));
+ }
+
+ /**
+ * Returns whether an OpenId Connect (OIDC) URL is set.
+ *
+ * @return whether an OpenId Connect URL is set
+ */
+ public boolean isOidcEnabled() {
+ return !StringUtils.isBlank(getOidcDiscoveryUrl());
+ }
+
+ /**
+ * Returns the OpenId Connect (OIDC) URL. Null otherwise.
+ *
+ * @return OIDC discovery url
+ */
+ public String getOidcDiscoveryUrl() {
+ return getProperty(SECURITY_USER_OIDC_DISCOVERY_URL);
+ }
+
+ /**
+ * Returns the OpenId Connect connect timeout. Non null.
+ *
+ * @return OIDC connect timeout
+ */
+ public String getOidcConnectTimeout() {
+ return getProperty(SECURITY_USER_OIDC_CONNECT_TIMEOUT, DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
+ }
+
+ /**
+ * Returns the OpenId Connect read timeout. Non null.
+ *
+ * @return OIDC read timeout
+ */
+ public String getOidcReadTimeout() {
+ return getProperty(SECURITY_USER_OIDC_READ_TIMEOUT, DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
+ }
+
+ /**
+ * Returns the OpenId Connect client id.
+ *
+ * @return OIDC client id
+ */
+ public String getOidcClientId() {
+ return getProperty(SECURITY_USER_OIDC_CLIENT_ID);
+ }
+
+ /**
+ * Returns the OpenId Connect client secret.
+ *
+ * @return OIDC client secret
+ */
+ public String getOidcClientSecret() {
+ return getProperty(SECURITY_USER_OIDC_CLIENT_SECRET);
+ }
+
+ /**
+ * Returns the preferred json web signature algorithm. May be null/blank.
+ *
+ * @return OIDC preferred json web signature algorithm
+ */
+ public String getOidcPreferredJwsAlgorithm() {
+ return getProperty(SECURITY_USER_OIDC_PREFERRED_JWSALGORITHM);
+ }
+
+ /**
+ * Returns additional scopes to be sent when requesting the access token from the IDP.
+ *
+ * @return List of additional scopes to be sent
+ */
+ public List<String> getOidcAdditionalScopes() {
+ String rawProperty = getProperty(SECURITY_USER_OIDC_ADDITIONAL_SCOPES, "");
+ if (rawProperty.isEmpty()) {
+ return new ArrayList<>();
+ }
+ List<String> additionalScopes = Arrays.asList(rawProperty.split(","));
+ return additionalScopes.stream().map(String::trim).collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the claim to be used to identify a user.
+ * Claim must be requested by adding the scope for it.
+ * Default is 'email'.
+ *
+ * @return The claim to be used to identify the user.
+ */
+ public String getOidcClaimIdentifyingUser() {
+ return getProperty(SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER, "email").trim();
+ }
+
}
diff --git a/nifi-registry-core/nifi-registry-properties/src/test/groovy/org/apache/nifi/registry/properties/NiFiRegistryPropertiesGroovyTest.groovy b/nifi-registry-core/nifi-registry-properties/src/test/groovy/org/apache/nifi/registry/properties/NiFiRegistryPropertiesGroovyTest.groovy
index 0c403cd..c7a0a4d 100644
--- a/nifi-registry-core/nifi-registry-properties/src/test/groovy/org/apache/nifi/registry/properties/NiFiRegistryPropertiesGroovyTest.groovy
+++ b/nifi-registry-core/nifi-registry-properties/src/test/groovy/org/apache/nifi/registry/properties/NiFiRegistryPropertiesGroovyTest.groovy
@@ -21,6 +21,8 @@
import org.junit.runners.JUnit4
import org.slf4j.Logger
import org.slf4j.LoggerFactory
+import static org.mockito.Mockito.mock
+import static org.mockito.Mockito.when
@RunWith(JUnit4.class)
class NiFiRegistryPropertiesGroovyTest extends GroovyTestCase {
@@ -118,4 +120,29 @@
assert emptyProperties.getPropertyKeys() == [] as Set
}
+ @Test
+ void testAdditionalOidcScopesAreTrimmed() {
+ final String scope = "abc"
+ final String scopeLeadingWhitespace = " def"
+ final String scopeTrailingWhitespace = "ghi "
+ final String scopeLeadingTrailingWhitespace = " jkl "
+
+ String additionalScopes = String.join(",", scope, scopeLeadingWhitespace,
+ scopeTrailingWhitespace, scopeLeadingTrailingWhitespace)
+
+ NiFiRegistryProperties properties = mock(NiFiRegistryProperties.class)
+ when(properties.getProperty(NiFiRegistryProperties.SECURITY_USER_OIDC_ADDITIONAL_SCOPES, ""))
+ .thenReturn(additionalScopes)
+ when(properties.getOidcAdditionalScopes()).thenCallRealMethod()
+
+ List<String> scopes = properties.getOidcAdditionalScopes()
+
+ assertTrue(scopes.contains(scope));
+ assertFalse(scopes.contains(scopeLeadingWhitespace));
+ assertTrue(scopes.contains(scopeLeadingWhitespace.trim()));
+ assertFalse(scopes.contains(scopeTrailingWhitespace));
+ assertTrue(scopes.contains(scopeTrailingWhitespace.trim()));
+ assertFalse(scopes.contains(scopeLeadingTrailingWhitespace));
+ assertTrue(scopes.contains(scopeLeadingTrailingWhitespace.trim()));
+ }
}
diff --git a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
index bf3e09f..db5b429 100644
--- a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
+++ b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
@@ -100,6 +100,14 @@
nifi.registry.kerberos.spnego.keytab.location=${nifi.registry.kerberos.spnego.keytab.location}
nifi.registry.kerberos.spnego.authentication.expiration=${nifi.registry.kerberos.spnego.authentication.expiration}
+# OIDC #
+nifi.registry.security.user.oidc.discovery.url=${nifi.registry.security.user.oidc.discovery.url}
+nifi.registry.security.user.oidc.connect.timeout=${nifi.registry.security.user.oidc.connect.timeout}
+nifi.registry.security.user.oidc.read.timeout=${nifi.registry.security.user.oidc.read.timeout}
+nifi.registry.security.user.oidc.client.id=${nifi.registry.security.user.oidc.client.id}
+nifi.registry.security.user.oidc.client.secret=${nifi.registry.security.user.oidc.client.secret}
+nifi.registry.security.user.oidc.preferred.jwsalgorithm=${nifi.registry.security.user.oidc.preferred.jwsalgorithm}
+
# revision management #
# This feature should remain disabled until a future NiFi release that supports the revision API changes
nifi.registry.revisions.enabled=${nifi.registry.revisions.enabled}
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/pom.xml b/nifi-registry-core/nifi-registry-web-api/pom.xml
index 4bf93d1..4d1df43 100644
--- a/nifi-registry-core/nifi-registry-web-api/pom.xml
+++ b/nifi-registry-core/nifi-registry-web-api/pom.xml
@@ -469,6 +469,17 @@
<scope>test</scope>
</dependency>
<dependency>
+ <groupId>org.spockframework</groupId>
+ <artifactId>spock-core</artifactId>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>org.codehaus.groovy</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
<groupId>cglib</groupId>
<artifactId>cglib-nodep</artifactId>
<version>2.2.2</version>
@@ -480,5 +491,25 @@
<version>${jetty.version}</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.codehaus.groovy</groupId>
+ <artifactId>groovy-json</artifactId>
+ <version>2.5.4</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.codehaus.groovy</groupId>
+ <artifactId>groovy</artifactId>
+ <version>2.5.4</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
</project>
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/AccessResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/AccessResource.java
index d8275cb..3c5db26 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/AccessResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/AccessResource.java
@@ -16,6 +16,14 @@
*/
package org.apache.nifi.registry.web.api;
+import com.nimbusds.oauth2.sdk.AuthorizationCode;
+import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.State;
+import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
+import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
+import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
import io.jsonwebtoken.JwtException;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -39,6 +47,7 @@
import org.apache.nifi.registry.web.exception.UnauthorizedException;
import org.apache.nifi.registry.web.security.authentication.jwt.JwtService;
import org.apache.nifi.registry.web.security.authentication.kerberos.KerberosSpnegoIdentityProvider;
+import org.apache.nifi.registry.web.security.authentication.oidc.OidcService;
import org.apache.nifi.registry.web.security.authentication.x509.X509IdentityProvider;
import org.apache.nifi.registry.web.service.ServiceFacade;
import org.slf4j.Logger;
@@ -47,6 +56,8 @@
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
+import javax.servlet.ServletContext;
+import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.Consumes;
@@ -59,10 +70,13 @@
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
+import java.util.UUID;
import java.util.stream.Collectors;
@Component
@@ -75,17 +89,25 @@
private static final Logger logger = LoggerFactory.getLogger(AccessResource.class);
+ private static final String OIDC_REQUEST_IDENTIFIER = "oidc-request-identifier";
+ private static final String OIDC_ERROR_TITLE = "Unable to continue login sequence";
+
private NiFiRegistryProperties properties;
private JwtService jwtService;
+ private OidcService oidcService;
private X509IdentityProvider x509IdentityProvider;
private KerberosSpnegoIdentityProvider kerberosSpnegoIdentityProvider;
private IdentityProvider identityProvider;
+ @Context
+ protected UriInfo uriInfo;
+
@Autowired
public AccessResource(
NiFiRegistryProperties properties,
JwtService jwtService,
X509IdentityProvider x509IdentityProvider,
+ OidcService oidcService,
@Nullable KerberosSpnegoIdentityProvider kerberosSpnegoIdentityProvider,
@Nullable IdentityProvider identityProvider,
ServiceFacade serviceFacade,
@@ -94,6 +116,7 @@
this.properties = properties;
this.jwtService = jwtService;
this.x509IdentityProvider = x509IdentityProvider;
+ this.oidcService = oidcService;
this.kerberosSpnegoIdentityProvider = kerberosSpnegoIdentityProvider;
this.identityProvider = identityProvider;
}
@@ -124,12 +147,12 @@
}
final CurrentUser currentUser = serviceFacade.getCurrentUser();
- currentUser.setLoginSupported(httpServletRequest.isSecure() && identityProvider != null);
+ currentUser.setLoginSupported(isBasicLoginSupported(httpServletRequest));
+ currentUser.setOIDCLoginSupported(isOIDCLoginSupported(httpServletRequest));
return generateOkResponse(currentUser).build();
}
-
/**
* Creates a token for accessing the REST API.
*
@@ -498,6 +521,260 @@
}
+ @GET
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.WILDCARD)
+ @Path("/oidc/request")
+ @ApiOperation(
+ value = "Initiates a request to authenticate through the configured OpenId Connect provider.",
+ notes = NON_GUARANTEED_ENDPOINT
+ )
+ public void oidcRequest(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+ // only consider user specific access over https
+ if (!httpServletRequest.isSecure()) {
+ //forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS.");
+ throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
+ }
+
+ // ensure oidc is enabled
+ if (!oidcService.isOidcEnabled()) {
+ //forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured.");
+ throw new IllegalStateException("OpenId Connect is not configured.");
+ }
+
+ final String oidcRequestIdentifier = UUID.randomUUID().toString();
+
+ // generate a cookie to associate this login sequence
+ final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, oidcRequestIdentifier);
+ cookie.setPath("/");
+ cookie.setHttpOnly(true);
+ cookie.setMaxAge(60);
+ cookie.setSecure(true);
+ httpServletResponse.addCookie(cookie);
+
+ // get the state for this request
+ final State state = oidcService.createState(oidcRequestIdentifier);
+
+ // build the authorization uri
+ final URI authorizationUri = UriBuilder.fromUri(oidcService.getAuthorizationEndpoint())
+ .queryParam("client_id", oidcService.getClientId())
+ .queryParam("response_type", "code")
+ .queryParam("scope", oidcService.getScope().toString())
+ .queryParam("state", state.getValue())
+ .queryParam("redirect_uri", getOidcCallback())
+ .build();
+
+ // generate the response
+ httpServletResponse.sendRedirect(authorizationUri.toString());
+ }
+
+ @GET
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.WILDCARD)
+ @Path("/oidc/callback")
+ @ApiOperation(
+ value = "Redirect/callback URI for processing the result of the OpenId Connect login sequence.",
+ notes = NON_GUARANTEED_ENDPOINT
+ )
+ public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+ // only consider user specific access over https
+ if (!httpServletRequest.isSecure()) {
+ //forwardToMessagePage(httpServletRequest, httpServletResponse, "User authentication/authorization is only supported when running over HTTPS.");
+ throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
+ }
+
+ // ensure oidc is enabled
+ if (!oidcService.isOidcEnabled()) {
+ //forwardToMessagePage(httpServletRequest, httpServletResponse, "OpenId Connect is not configured.");
+ throw new IllegalStateException("OpenId Connect is not configured.");
+ }
+
+ final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
+ if (oidcRequestIdentifier == null) {
+ throw new IllegalStateException("The login request identifier was not found in the request. Unable to continue.");
+ }
+
+ final com.nimbusds.openid.connect.sdk.AuthenticationResponse oidcResponse;
+ try {
+ oidcResponse = AuthenticationResponseParser.parse(getRequestUri());
+ } catch (final ParseException e) {
+ logger.error("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process.");
+
+ // remove the oidc request cookie
+ removeOidcRequestCookie(httpServletResponse);
+
+ // forward to the error page
+ throw new IllegalStateException("Unable to parse the redirect URI from the OpenId Connect Provider. Unable to continue login process.");
+ }
+
+ if (oidcResponse.indicatesSuccess()) {
+ final AuthenticationSuccessResponse successfulOidcResponse = (AuthenticationSuccessResponse) oidcResponse;
+
+ // confirm state
+ final State state = successfulOidcResponse.getState();
+ if (state == null || !oidcService.isStateValid(oidcRequestIdentifier, state)) {
+ logger.error("The state value returned by the OpenId Connect Provider does not match the stored state. Unable to continue login process.");
+
+ // remove the oidc request cookie
+ removeOidcRequestCookie(httpServletResponse);
+
+ // forward to the error page
+ throw new IllegalStateException("Purposed state does not match the stored state. Unable to continue login process.");
+ }
+
+ try {
+ // exchange authorization code for id token
+ final AuthorizationCode authorizationCode = successfulOidcResponse.getAuthorizationCode();
+ final AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, URI.create(getOidcCallback()));
+ oidcService.exchangeAuthorizationCode(oidcRequestIdentifier, authorizationGrant);
+ } catch (final Exception e) {
+ logger.error("Unable to exchange authorization for ID token: " + e.getMessage(), e);
+
+ // remove the oidc request cookie
+ removeOidcRequestCookie(httpServletResponse);
+
+ // forward to the error page
+ throw new IllegalStateException("Unable to exchange authorization for ID token: " + e.getMessage());
+ }
+
+ // redirect to the name page
+ httpServletResponse.sendRedirect(getNiFiRegistryUri());
+ } else {
+ // remove the oidc request cookie
+ removeOidcRequestCookie(httpServletResponse);
+
+ // report the unsuccessful login
+ final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse;
+ throw new IllegalStateException("Unsuccessful login attempt: " + errorOidcResponse.getErrorObject().getDescription());
+ }
+ }
+
+ @POST
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.TEXT_PLAIN)
+ @Path("/oidc/exchange")
+ @ApiOperation(
+ value = "Retrieves a JWT following a successful login sequence using the configured OpenId Connect provider.",
+ response = String.class,
+ notes = NON_GUARANTEED_ENDPOINT
+ )
+ public Response oidcExchange(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+ // only consider user specific access over https
+ if (!httpServletRequest.isSecure()) {
+ throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
+ }
+
+ // ensure oidc is enabled
+ if (!oidcService.isOidcEnabled()) {
+ throw new IllegalStateException("OpenId Connect is not configured.");
+ }
+
+ final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
+ if (oidcRequestIdentifier == null) {
+ throw new IllegalArgumentException("The login request identifier was not found in the request. Unable to continue.");
+ }
+
+ // remove the oidc request cookie
+ removeOidcRequestCookie(httpServletResponse);
+
+ // get the jwt
+ final String jwt = oidcService.getJwt(oidcRequestIdentifier);
+ if (jwt == null) {
+ throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue.");
+ }
+
+ // generate the response
+ return generateOkResponse(jwt).build();
+ }
+
+ @DELETE
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.WILDCARD)
+ @Path("/oidc/logout")
+ @ApiOperation(
+ value = "Performs a logout in the OpenId Provider.",
+ notes = NON_GUARANTEED_ENDPOINT
+ )
+ public void oidcLogout(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+ if (!httpServletRequest.isSecure()) {
+ throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
+ }
+
+ if (!oidcService.isOidcEnabled()) {
+ throw new IllegalStateException("OpenId Connect is not configured.");
+ }
+
+ final String tokenHeader = httpServletRequest.getHeader(JwtService.AUTHORIZATION);
+ jwtService.logOutUsingAuthHeader(tokenHeader);
+
+ URI endSessionEndpoint = oidcService.getEndSessionEndpoint();
+ String postLogoutRedirectUri = generateResourceUri("..", "nifi-registry");
+
+ if (endSessionEndpoint == null) {
+ // handle the case, where the OpenID Provider does not have an end session endpoint
+ //httpServletResponse.sendRedirect(postLogoutRedirectUri);
+ } else {
+ URI logoutUri = UriBuilder.fromUri(endSessionEndpoint)
+ .queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
+ .build();
+ httpServletResponse.sendRedirect(logoutUri.toString());
+ }
+ }
+
+ /**
+ * Gets the value of a cookie matching the specified name. If no cookie with that name exists, null is returned.
+ *
+ * @param cookies the cookies
+ * @param name the name of the cookie
+ * @return the value of the corresponding cookie, or null if the cookie does not exist
+ */
+ private String getCookieValue(final Cookie[] cookies, final String name) {
+ if (cookies != null) {
+ for (final Cookie cookie : cookies) {
+ if (name.equals(cookie.getName())) {
+ return cookie.getValue();
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public void setOidcService(OidcService oidcService) {
+ this.oidcService = oidcService;
+ }
+
+ private String getOidcCallback() {
+ return generateResourceUri("access", "oidc", "callback");
+ }
+
+ private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) {
+ final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, null);
+ cookie.setPath("/");
+ cookie.setHttpOnly(true);
+ cookie.setMaxAge(0);
+ cookie.setSecure(true);
+ httpServletResponse.addCookie(cookie);
+ }
+
+ protected URI getRequestUri() {
+ return uriInfo.getRequestUri();
+ }
+
+ private String getNiFiRegistryUri() {
+ final String nifiRegistryApiUrl = generateResourceUri();
+ final String baseUrl = StringUtils.substringBeforeLast(nifiRegistryApiUrl, "/nifi-registry-api");
+ return baseUrl + "/nifi-registry";
+ }
+
+ private void forwardToMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
+ httpServletRequest.setAttribute("title", OIDC_ERROR_TITLE);
+ httpServletRequest.setAttribute("messages", message);
+
+ final ServletContext uiContext = httpServletRequest.getServletContext().getContext("/nifi-registry");
+ uiContext.getRequestDispatcher("/WEB-INF/pages/message-page.jsp").forward(httpServletRequest, httpServletResponse);
+ }
+
private String createAccessToken(IdentityProvider identityProvider, AuthenticationRequest authenticationRequest)
throws InvalidCredentialsException, AdministrationException {
@@ -548,4 +825,11 @@
return identityProviderWaterfall;
}
+ private boolean isBasicLoginSupported(HttpServletRequest request) {
+ return request.isSecure() && identityProvider != null;
+ }
+
+ private boolean isOIDCLoginSupported(HttpServletRequest request) {
+ return request.isSecure() && oidcService != null && oidcService.isOidcEnabled();
+ }
}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java
index e2cf0e0..d1b6012 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/NiFiRegistrySecurityConfig.java
@@ -88,7 +88,8 @@
@Override
public void configure(WebSecurity webSecurity) throws Exception {
// allow any client to access the endpoint for logging in to generate an access token
- webSecurity.ignoring().antMatchers( "/access/token/**");
+ webSecurity.ignoring().antMatchers( "/access/token", "/access/token/kerberos",
+ "/access/oidc/exchange", "/access/oidc/callback", "/access/oidc/request", "/access/token/identity-provider" );
}
@Override
@@ -127,7 +128,6 @@
// but before the Jersey application endpoints get the request,
// insert the ResourceAuthorizationFilter to do its authorization checks
http.addFilterAfter(resourceAuthorizationFilter(), FilterSecurityInterceptor.class);
-
}
@Override
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
index d24e665..a0e2d25 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/jwt/JwtService.java
@@ -31,6 +31,7 @@
import org.apache.nifi.registry.security.authentication.AuthenticationResponse;
import org.apache.nifi.registry.security.key.Key;
import org.apache.nifi.registry.security.key.KeyService;
+import org.apache.nifi.registry.web.security.authentication.exception.InvalidAuthenticationException;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -39,6 +40,8 @@
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
// TODO, look into replacing this JwtService service with Apache Licensed JJWT library
@Service
@@ -49,6 +52,8 @@
private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
private static final String KEY_ID_CLAIM = "kid";
private static final String USERNAME_CLAIM = "preferred_username";
+ private static final Pattern tokenPattern = Pattern.compile("^Bearer (\\S*\\.\\S*\\.\\S*)$");
+ public static final String AUTHORIZATION = "Authorization";
private final KeyService keyService;
@@ -223,4 +228,18 @@
.append(" ms remaining]")
.toString();
}
+
+ public void logOutUsingAuthHeader(String authorizationHeader) {
+ String base64EncodedToken = getTokenFromHeader(authorizationHeader);
+ logOut(getAuthenticationFromToken(base64EncodedToken));
+ }
+
+ public static String getTokenFromHeader(String authenticationHeader) {
+ Matcher matcher = tokenPattern.matcher(authenticationHeader);
+ if(matcher.matches()) {
+ return matcher.group(1);
+ } else {
+ throw new InvalidAuthenticationException("JWT did not match expected pattern.");
+ }
+ }
}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcIdentityProvider.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcIdentityProvider.java
new file mode 100644
index 0000000..53e3fe2
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcIdentityProvider.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc;
+
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+
+import java.io.IOException;
+import java.net.URI;
+
+public interface OidcIdentityProvider {
+
+ String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED = "OpenId Connect support is not configured";
+
+ /**
+ * Initializes the provider.
+ */
+ void initializeProvider();
+
+ /**
+ * Returns whether OIDC support is enabled.
+ *
+ * @return whether OIDC support is enabled
+ */
+ boolean isOidcEnabled();
+
+ /**
+ * Returns the configured client id.
+ *
+ * @return the client id
+ */
+ ClientID getClientId();
+
+ /**
+ * Returns the URI for the authorization endpoint.
+ *
+ * @return uri for the authorization endpoint
+ */
+ URI getAuthorizationEndpoint();
+
+ /**
+ * Returns the URI for the end session endpoint.
+ *
+ * @return uri for the end session endpoint
+ */
+ URI getEndSessionEndpoint();
+
+ /**
+ * Returns the scopes supported by the OIDC provider.
+ *
+ * @return support scopes
+ */
+ Scope getScope();
+
+ /**
+ * Exchanges the supplied authorization grant for an ID token. Extracts the identity from the ID
+ * token and converts it into NiFi JWT.
+ *
+ * @param authorizationGrant authorization grant for invoking the Token Endpoint
+ * @return a NiFi JWT
+ * @throws IOException if there was an exceptional error while communicating with the OIDC provider
+ */
+ String exchangeAuthorizationCode(AuthorizationGrant authorizationGrant) throws IOException;
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcService.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcService.java
new file mode 100644
index 0000000..65b160b
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcService.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.id.State;
+import org.apache.nifi.registry.web.security.authentication.util.CacheKey;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * OidcService is a service for managing the OpenId Connect Authorization flow.
+ */
+@Service
+public class OidcService {
+
+ private OidcIdentityProvider identityProvider;
+ private Cache<CacheKey, State> stateLookupForPendingRequests; // identifier from cookie -> state value
+ private Cache<CacheKey, String> jwtLookupForCompletedRequests; // identifier from cookie -> jwt or identity (and generate jwt on retrieval)
+
+ /**
+ * Creates a new OtpService with an expiration of 1 minute.
+ *
+ * @param identityProvider The identity provider
+ */
+ @Autowired
+ public OidcService(final OidcIdentityProvider identityProvider) {
+ this(identityProvider, 60, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Creates a new OtpService.
+ *
+ * @param identityProvider The identity provider
+ * @param duration The expiration duration
+ * @param units The expiration units
+ * @throws NullPointerException If units is null
+ * @throws IllegalArgumentException If duration is negative
+ */
+ public OidcService(final OidcIdentityProvider identityProvider, final int duration, final TimeUnit units) {
+ if (identityProvider == null) {
+ throw new RuntimeException("The OidcIdentityProvider must be specified.");
+ }
+
+ identityProvider.initializeProvider();
+ this.identityProvider = identityProvider;
+ this.stateLookupForPendingRequests = CacheBuilder.newBuilder().expireAfterWrite(duration, units).build();
+ this.jwtLookupForCompletedRequests = CacheBuilder.newBuilder().expireAfterWrite(duration, units).build();
+ }
+
+ /**
+ * Returns whether OpenId Connect is enabled.
+ *
+ * @return whether OpenId Connect is enabled
+ */
+ public boolean isOidcEnabled() {
+ return identityProvider.isOidcEnabled();
+ }
+
+ /**
+ * Returns the OpenId Connect authorization endpoint.
+ *
+ * @return the authorization endpoint
+ */
+ public URI getAuthorizationEndpoint() {
+ return identityProvider.getAuthorizationEndpoint();
+ }
+
+ /**
+ * Returns the OpenId Connect end session endpoint.
+ *
+ * @return the end session endpoint
+ */
+ public URI getEndSessionEndpoint() {
+ return identityProvider.getEndSessionEndpoint();
+ }
+
+ /**
+ * Returns the OpenId Connect scope.
+ *
+ * @return scope
+ */
+ public Scope getScope() {
+ return identityProvider.getScope();
+ }
+
+ /**
+ * Returns the OpenId Connect client id.
+ *
+ * @return client id
+ */
+ public String getClientId() {
+ return identityProvider.getClientId().getValue();
+ }
+
+ /**
+ * Initiates an OpenId Connection authorization code flow using the specified request identifier to maintain state.
+ *
+ * @param oidcRequestIdentifier request identifier
+ * @return state
+ */
+ public State createState(final String oidcRequestIdentifier) {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
+ final State state = new State(generateStateValue());
+
+ try {
+ synchronized (stateLookupForPendingRequests) {
+ final State cachedState = stateLookupForPendingRequests.get(oidcRequestIdentifierKey, () -> state);
+ if (!timeConstantEqualityCheck(state.getValue(), cachedState.getValue())) {
+ throw new IllegalStateException("An existing login request is already in progress.");
+ }
+ }
+ } catch (ExecutionException e) {
+ throw new IllegalStateException("Unable to store the login request state.");
+ }
+
+ return state;
+ }
+
+ /**
+ * Generates a value to use as State in the OpenId Connect login sequence. 128 bits is considered cryptographically strong
+ * with current hardware/software, but a Base32 digit needs 5 bits to be fully encoded, so 128 is rounded up to 130. Base32
+ * is chosen because it encodes data with a single case and without including confusing or URI-incompatible characters,
+ * unlike Base64, but is approximately 20% more compact than Base16/hexadecimal
+ *
+ * @return the state value
+ */
+ private String generateStateValue() {
+ return new BigInteger(130, new SecureRandom()).toString(32);
+ }
+
+ /**
+ * Validates the proposed state with the given request identifier. Will return false if the
+ * state does not match or if entry for this request identifier has expired.
+ *
+ * @param oidcRequestIdentifier request identifier
+ * @param proposedState proposed state
+ * @return whether the state is valid or not
+ */
+ public boolean isStateValid(final String oidcRequestIdentifier, final State proposedState) {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ if (proposedState == null) {
+ throw new IllegalArgumentException("Proposed state must be specified.");
+ }
+
+ final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
+
+ synchronized (stateLookupForPendingRequests) {
+ final State state = stateLookupForPendingRequests.getIfPresent(oidcRequestIdentifierKey);
+ if (state != null) {
+ stateLookupForPendingRequests.invalidate(oidcRequestIdentifierKey);
+ }
+
+ return state != null && timeConstantEqualityCheck(state.getValue(), proposedState.getValue());
+ }
+ }
+
+ /**
+ * Exchanges the specified authorization grant for an ID token for the given request identifier.
+ *
+ * @param oidcRequestIdentifier request identifier
+ * @param authorizationGrant authorization grant
+ * @throws IOException exceptional case for communication error with the OpenId Connect provider
+ */
+ public void exchangeAuthorizationCode(final String oidcRequestIdentifier, final AuthorizationGrant authorizationGrant) throws IOException {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
+ final String nifiJwt = identityProvider.exchangeAuthorizationCode(authorizationGrant);
+
+ try {
+ // cache the jwt for later retrieval
+ synchronized (jwtLookupForCompletedRequests) {
+ final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, () -> nifiJwt);
+ if (!timeConstantEqualityCheck(nifiJwt, cachedJwt)) {
+ throw new IllegalStateException("An existing login request is already in progress.");
+ }
+ }
+ } catch (final ExecutionException e) {
+ throw new IllegalStateException("Unable to store the login authentication token.");
+ }
+ }
+
+ /**
+ * Returns the resulting JWT for the given request identifier. Will return null if the request
+ * identifier is not associated with a JWT or if the login sequence was not completed before
+ * this request identifier expired.
+ *
+ * @param oidcRequestIdentifier request identifier
+ * @return jwt token
+ */
+ public String getJwt(final String oidcRequestIdentifier) {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
+
+ synchronized (jwtLookupForCompletedRequests) {
+ final String jwt = jwtLookupForCompletedRequests.getIfPresent(oidcRequestIdentifierKey);
+ if (jwt != null) {
+ jwtLookupForCompletedRequests.invalidate(oidcRequestIdentifierKey);
+ }
+
+ return jwt;
+ }
+ }
+
+ /**
+ * Implements a time constant equality check. If either value is null, false is returned.
+ *
+ * @param value1 value1
+ * @param value2 value2
+ * @return if value1 equals value2
+ */
+ private boolean timeConstantEqualityCheck(final String value1, final String value2) {
+ if (value1 == null || value2 == null) {
+ return false;
+ }
+
+ return MessageDigest.isEqual(value1.getBytes(StandardCharsets.UTF_8), value2.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
new file mode 100644
index 0000000..f43bef0
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProvider.java
@@ -0,0 +1,466 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.properties.NiFiRegistryProperties;
+import org.apache.nifi.registry.security.authentication.exception.IdentityAccessException;
+import org.apache.nifi.registry.util.FormatUtils;
+import org.apache.nifi.registry.web.security.authentication.jwt.JwtService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.proc.BadJOSEException;
+import com.nimbusds.jose.util.DefaultResourceRetriever;
+import com.nimbusds.jose.util.ResourceRetriever;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.oauth2.sdk.AuthorizationGrant;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.Request;
+import com.nimbusds.oauth2.sdk.Scope;
+import com.nimbusds.oauth2.sdk.TokenErrorResponse;
+import com.nimbusds.oauth2.sdk.TokenRequest;
+import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretPost;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
+import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse;
+import com.nimbusds.openid.connect.sdk.UserInfoRequest;
+import com.nimbusds.openid.connect.sdk.UserInfoResponse;
+import com.nimbusds.openid.connect.sdk.UserInfoSuccessResponse;
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens;
+import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
+import net.minidev.json.JSONObject;
+
+/**
+ * OidcProvider for managing the OpenId Connect Authorization flow.
+ */
+@Component
+public class StandardOidcIdentityProvider implements OidcIdentityProvider {
+
+ private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProvider.class);
+ private final String EMAIL_CLAIM = "email";
+
+ private NiFiRegistryProperties properties;
+ private JwtService jwtService;
+ private OIDCProviderMetadata oidcProviderMetadata;
+ private int oidcConnectTimeout;
+ private int oidcReadTimeout;
+ private IDTokenValidator tokenValidator;
+ private ClientID clientId;
+ private Secret clientSecret;
+
+ /**
+ * Creates a new StandardOidcIdentityProvider.
+ *
+ * @param jwtService jwt service
+ * @param properties properties
+ */
+ @Autowired
+ public StandardOidcIdentityProvider(final JwtService jwtService, final NiFiRegistryProperties properties) {
+ this.properties = properties;
+ this.jwtService = jwtService;
+ }
+
+ /**
+ * Loads OIDC configuration values from {@link NiFiRegistryProperties}, connects to external OIDC provider, and retrieves
+ * and validates provider metadata.
+ */
+ @Override
+ public void initializeProvider() {
+ // attempt to process the oidc configuration if configured
+ if (!properties.isOidcEnabled()) {
+ logger.warn("The OIDC provider is not configured or enabled");
+ return;
+ }
+
+ validateOIDCConfiguration();
+
+ try {
+ // retrieve the oidc provider metadata
+ oidcProviderMetadata = retrieveOidcProviderMetadata(properties.getOidcDiscoveryUrl());
+ } catch (IOException | ParseException e) {
+ throw new RuntimeException("Unable to retrieve OpenId Connect Provider metadata from: " + properties.getOidcDiscoveryUrl(), e);
+ }
+
+ validateOIDCProviderMetadata();
+ }
+
+ /**
+ * Validates the retrieved OIDC provider metadata.
+ */
+ private void validateOIDCProviderMetadata() {
+ // ensure the authorization endpoint is present
+ if (oidcProviderMetadata.getAuthorizationEndpointURI() == null) {
+ throw new RuntimeException("OpenId Connect Provider metadata does not contain an Authorization Endpoint.");
+ }
+
+ // ensure the token endpoint is present
+ if (oidcProviderMetadata.getTokenEndpointURI() == null) {
+ throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint.");
+ }
+
+ // ensure the oidc provider supports basic or post client auth
+ List<ClientAuthenticationMethod> clientAuthenticationMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
+ logger.info("OpenId Connect: Available clientAuthenticationMethods {} ", clientAuthenticationMethods);
+ if (clientAuthenticationMethods == null || clientAuthenticationMethods.isEmpty()) {
+ clientAuthenticationMethods = new ArrayList<>();
+ clientAuthenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
+ oidcProviderMetadata.setTokenEndpointAuthMethods(clientAuthenticationMethods);
+ logger.warn("OpenId Connect: ClientAuthenticationMethods is null, Setting clientAuthenticationMethods as CLIENT_SECRET_BASIC");
+ } else if (!clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ && !clientAuthenticationMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
+ throw new RuntimeException(String.format("OpenId Connect Provider does not support %s or %s",
+ ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(),
+ ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()));
+ }
+
+ // extract the supported json web signature algorithms
+ final List<JWSAlgorithm> allowedAlgorithms = oidcProviderMetadata.getIDTokenJWSAlgs();
+ if (allowedAlgorithms == null || allowedAlgorithms.isEmpty()) {
+ throw new RuntimeException("The OpenId Connect Provider does not support any JWS algorithms.");
+ }
+
+ try {
+ // get the preferred json web signature algorithm
+ final String rawPreferredJwsAlgorithm = properties.getOidcPreferredJwsAlgorithm();
+
+ final JWSAlgorithm preferredJwsAlgorithm;
+ if (StringUtils.isBlank(rawPreferredJwsAlgorithm)) {
+ preferredJwsAlgorithm = JWSAlgorithm.RS256;
+ } else {
+ if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) {
+ preferredJwsAlgorithm = null;
+ } else {
+ preferredJwsAlgorithm = JWSAlgorithm.parse(rawPreferredJwsAlgorithm);
+ }
+ }
+
+ if (preferredJwsAlgorithm == null) {
+ tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId);
+ } else if (JWSAlgorithm.HS256.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS384.equals(preferredJwsAlgorithm) || JWSAlgorithm.HS512.equals(preferredJwsAlgorithm)) {
+ tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, clientSecret);
+ } else {
+ final ResourceRetriever retriever = new DefaultResourceRetriever(oidcConnectTimeout, oidcReadTimeout);
+ tokenValidator = new IDTokenValidator(oidcProviderMetadata.getIssuer(), clientId, preferredJwsAlgorithm, oidcProviderMetadata.getJWKSetURI().toURL(), retriever);
+ }
+ } catch (final Exception e) {
+ throw new RuntimeException("Unable to create the ID token validator for the configured OpenId Connect Provider: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Loads the initial configuration values relating to the OIDC provider from the class {@link NiFiRegistryProperties} and populates the individual fields.
+ */
+ private void validateOIDCConfiguration() {
+ if (properties.isLoginIdentityProviderEnabled()) {
+ throw new RuntimeException("OpenId Connect support cannot be enabled if the Login Identity Provider or Apache Knox SSO is configured.");
+ }
+
+ // oidc connect timeout
+ final String rawConnectTimeout = properties.getOidcConnectTimeout();
+ try {
+ oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(rawConnectTimeout, TimeUnit.MILLISECONDS);
+ } catch (final Exception e) {
+ logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
+ NiFiRegistryProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT, rawConnectTimeout, NiFiRegistryProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
+ oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiRegistryProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS);
+ }
+
+ // oidc read timeout
+ final String rawReadTimeout = properties.getOidcReadTimeout();
+ try {
+ oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(rawReadTimeout, TimeUnit.MILLISECONDS);
+ } catch (final Exception e) {
+ logger.warn("Failed to parse value of property '{}' as a valid time period. Value was '{}'. Ignoring this value and using the default value of '{}'",
+ NiFiRegistryProperties.SECURITY_USER_OIDC_READ_TIMEOUT, rawReadTimeout, NiFiRegistryProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
+ oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiRegistryProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT, TimeUnit.MILLISECONDS);
+ }
+
+ // client id
+ final String rawClientId = properties.getOidcClientId();
+ if (StringUtils.isBlank(rawClientId)) {
+ throw new RuntimeException("Client ID is required when configuring an OIDC Provider.");
+ }
+ clientId = new ClientID(rawClientId);
+
+ // client secret
+ final String rawClientSecret = properties.getOidcClientSecret();
+ if (StringUtils.isBlank(rawClientSecret)) {
+ throw new RuntimeException("Client secret is required when configuring an OIDC Provider.");
+ }
+ clientSecret = new Secret(rawClientSecret);
+ }
+
+ /**
+ * Returns the retrieved OIDC provider metadata from the external provider.
+ *
+ * @param discoveryUri the remote OIDC provider endpoint for service discovery
+ * @return the provider metadata
+ * @throws IOException if there is a problem connecting to the remote endpoint
+ * @throws ParseException if there is a problem parsing the response
+ */
+ private OIDCProviderMetadata retrieveOidcProviderMetadata(final String discoveryUri) throws IOException, ParseException {
+ final URL url = new URL(discoveryUri);
+ final HTTPRequest httpRequest = new HTTPRequest(HTTPRequest.Method.GET, url);
+ httpRequest.setConnectTimeout(oidcConnectTimeout);
+ httpRequest.setReadTimeout(oidcReadTimeout);
+
+ final HTTPResponse httpResponse = httpRequest.send();
+
+ if (httpResponse.getStatusCode() != 200) {
+ throw new IOException("Unable to download OpenId Connect Provider metadata from " + url + ": Status code " + httpResponse.getStatusCode());
+ }
+
+ final JSONObject jsonObject = httpResponse.getContentAsJSONObject();
+ return OIDCProviderMetadata.parse(jsonObject);
+ }
+
+ @Override
+ public boolean isOidcEnabled() {
+ return properties.isOidcEnabled();
+ }
+
+ @Override
+ public URI getAuthorizationEndpoint() {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ return oidcProviderMetadata.getAuthorizationEndpointURI();
+ }
+
+ @Override
+ public URI getEndSessionEndpoint() {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+ return oidcProviderMetadata.getEndSessionEndpointURI();
+ }
+
+ @Override
+ public Scope getScope() {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ Scope scope = new Scope("openid", EMAIL_CLAIM);
+
+ for (String additionalScope : properties.getOidcAdditionalScopes()) {
+ // Scope automatically prevents duplicated entries
+ scope.add(additionalScope);
+ }
+
+ return scope;
+ }
+
+ @Override
+ public ClientID getClientId() {
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ return clientId;
+ }
+
+ @Override
+ public String exchangeAuthorizationCode(final AuthorizationGrant authorizationGrant) throws IOException {
+ // Check if OIDC is enabled
+ if (!isOidcEnabled()) {
+ throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
+ }
+
+ // Build ClientAuthentication
+ final ClientAuthentication clientAuthentication = createClientAuthentication();
+
+ try {
+ // Build the token request
+ final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication);
+ return authorizeClient(tokenHttpRequest);
+
+ } catch (final ParseException | JOSEException | BadJOSEException | java.text.ParseException e) {
+ throw new RuntimeException("Unable to parse the response from the Token request: " + e.getMessage());
+ }
+ }
+
+ private String authorizeClient(HTTPRequest tokenHttpRequest) throws ParseException, IOException, BadJOSEException, JOSEException, java.text.ParseException {
+ // Get the token response
+ final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send());
+
+ // Handle success
+ if (response.indicatesSuccess()) {
+ return convertOIDCTokenToNiFiToken((OIDCTokenResponse) response);
+ } else {
+ // If the response was not successful
+ final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
+ throw new RuntimeException("An error occurred while invoking the Token endpoint: " +
+ errorResponse.getErrorObject().getDescription());
+ }
+ }
+
+ private String convertOIDCTokenToNiFiToken(OIDCTokenResponse response) throws BadJOSEException, JOSEException, java.text.ParseException, IOException {
+ final OIDCTokenResponse oidcTokenResponse = response;
+ final OIDCTokens oidcTokens = oidcTokenResponse.getOIDCTokens();
+ final JWT oidcJwt = oidcTokens.getIDToken();
+
+ // validate the token - no nonce required for authorization code flow
+ final IDTokenClaimsSet claimsSet = tokenValidator.validate(oidcJwt, null);
+
+ // attempt to extract the configured claim to access the user's identity; default is 'email'
+ String identityClaim = properties.getOidcClaimIdentifyingUser();
+ String identity = claimsSet.getStringClaim(identityClaim);
+
+ // If default identity not available, attempt secondary identity extraction
+ if (StringUtils.isBlank(identity)) {
+ // Provide clear message to admin that desired claim is missing and present available claims
+ List<String> availableClaims = getAvailableClaims(oidcJwt.getJWTClaimsSet());
+ logger.warn("Failed to obtain the identity of the user with the claim '{}'. The available claims on " +
+ "the OIDC response are: {}. Will attempt to obtain the identity from secondary sources",
+ identityClaim, availableClaims);
+
+ // If the desired user claim was not "email" and "email" is present, use that
+ if (!identityClaim.equalsIgnoreCase(EMAIL_CLAIM) && availableClaims.contains(EMAIL_CLAIM)) {
+ identity = claimsSet.getStringClaim(EMAIL_CLAIM);
+ logger.info("The 'email' claim was present. Using that claim to avoid extra remote call");
+ } else {
+ identity = retrieveIdentityFromUserInfoEndpoint(oidcTokens);
+ logger.info("Retrieved identity from UserInfo endpoint");
+ }
+ }
+
+ // extract expiration details from the claims set
+ final Calendar now = Calendar.getInstance();
+ final Date expiration = claimsSet.getExpirationTime();
+ final long expiresIn = expiration.getTime() - now.getTimeInMillis();
+ final String issuer = claimsSet.getIssuer().getValue();
+
+ // convert into a nifi jwt for retrieval later
+ return jwtService.generateSignedToken(identity, identity, issuer, issuer, expiresIn);
+ }
+
+ private String retrieveIdentityFromUserInfoEndpoint(OIDCTokens oidcTokens) throws IOException {
+ // explicitly try to get the identity from the UserInfo endpoint with the configured claim
+ // extract the bearer access token
+ final BearerAccessToken bearerAccessToken = oidcTokens.getBearerAccessToken();
+ if (bearerAccessToken == null) {
+ throw new IllegalStateException("No access token found in the ID tokens");
+ }
+
+ // invoke the UserInfo endpoint
+ HTTPRequest userInfoRequest = createUserInfoRequest(bearerAccessToken);
+ return lookupIdentityInUserInfo(userInfoRequest);
+ }
+
+ private HTTPRequest createTokenHTTPRequest(AuthorizationGrant authorizationGrant, ClientAuthentication clientAuthentication) {
+ final TokenRequest request = new TokenRequest(oidcProviderMetadata.getTokenEndpointURI(), clientAuthentication, authorizationGrant);
+ return formHTTPRequest(request);
+ }
+
+ private HTTPRequest createUserInfoRequest(BearerAccessToken bearerAccessToken) {
+ final UserInfoRequest request = new UserInfoRequest(oidcProviderMetadata.getUserInfoEndpointURI(), bearerAccessToken);
+ return formHTTPRequest(request);
+ }
+
+ private HTTPRequest formHTTPRequest(Request request) {
+ final HTTPRequest httpRequest = request.toHTTPRequest();
+ httpRequest.setConnectTimeout(oidcConnectTimeout);
+ httpRequest.setReadTimeout(oidcReadTimeout);
+ return httpRequest;
+ }
+
+ private ClientAuthentication createClientAuthentication() {
+ final ClientAuthentication clientAuthentication;
+ List<ClientAuthenticationMethod> authMethods = oidcProviderMetadata.getTokenEndpointAuthMethods();
+ if (authMethods != null && authMethods.contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
+ clientAuthentication = new ClientSecretPost(clientId, clientSecret);
+ } else {
+ clientAuthentication = new ClientSecretBasic(clientId, clientSecret);
+ }
+ return clientAuthentication;
+ }
+
+ private static List<String> getAvailableClaims(JWTClaimsSet claimSet) {
+ // Get the claims available in the ID token response
+ List<String> presentClaims = claimSet.getClaims().entrySet().stream()
+ // Check claim values are not empty
+ .filter(e -> StringUtils.isNotBlank(e.getValue().toString()))
+ // If not empty, put claim name in a map
+ .map(Map.Entry::getKey)
+ .sorted()
+ .collect(Collectors.toList());
+ return presentClaims;
+ }
+
+ private String lookupIdentityInUserInfo(final HTTPRequest userInfoHttpRequest) throws IOException {
+ try {
+ // send the user request
+ final UserInfoResponse response = UserInfoResponse.parse(userInfoHttpRequest.send());
+
+ // interpret the details
+ if (response.indicatesSuccess()) {
+ final UserInfoSuccessResponse successResponse = (UserInfoSuccessResponse) response;
+
+ final JWTClaimsSet claimsSet;
+ if (successResponse.getUserInfo() != null) {
+ claimsSet = successResponse.getUserInfo().toJWTClaimsSet();
+ } else {
+ claimsSet = successResponse.getUserInfoJWT().getJWTClaimsSet();
+ }
+
+ final String identity = claimsSet.getStringClaim(properties.getOidcClaimIdentifyingUser());
+
+ // ensure we were able to get the user's identity
+ if (StringUtils.isBlank(identity)) {
+ throw new IllegalStateException("Unable to extract identity from the UserInfo token using the claim '" +
+ properties.getOidcClaimIdentifyingUser() + "'.");
+ } else {
+ return identity;
+ }
+ } else {
+ final UserInfoErrorResponse errorResponse = (UserInfoErrorResponse) response;
+ throw new IdentityAccessException("An error occurred while invoking the UserInfo endpoint: " + errorResponse.getErrorObject().getDescription());
+ }
+ } catch (final ParseException | java.text.ParseException e) {
+ throw new IdentityAccessException("Unable to parse the response from the UserInfo token request: " + e.getMessage());
+ }
+ }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/util/CacheKey.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/util/CacheKey.java
new file mode 100644
index 0000000..e8c56e6
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/util/CacheKey.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+
+/**
+ * An authentication token that represents an Authenticated and Authorized user of the NiFi Apis. The authorities are based off the specified UserDetails.
+ */
+
+/**
+ * Key for the cache. Necessary to override the default String.equals() to utilize MessageDigest.isEquals() to prevent timing attacks.
+ */
+public class CacheKey {
+ final String key;
+
+ public CacheKey(String key) {
+ this.key = key;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ final CacheKey otherCacheKey = (CacheKey) o;
+ return MessageDigest.isEqual(key.getBytes(StandardCharsets.UTF_8), otherCacheKey.key.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public int hashCode() {
+ return key.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "CacheKey{token ending in '..." + key.substring(key.length() - 6) + "'}";
+ }
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/groovy/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderGroovyTest.groovy b/nifi-registry-core/nifi-registry-web-api/src/test/groovy/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderGroovyTest.groovy
new file mode 100644
index 0000000..8381bda
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/groovy/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderGroovyTest.groovy
@@ -0,0 +1,580 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc
+
+import com.nimbusds.jwt.JWT
+import com.nimbusds.jwt.JWTClaimsSet
+import com.nimbusds.jwt.PlainJWT
+import com.nimbusds.oauth2.sdk.AuthorizationCode
+import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication
+import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic
+import com.nimbusds.oauth2.sdk.auth.ClientSecretPost
+import com.nimbusds.oauth2.sdk.auth.Secret
+import com.nimbusds.oauth2.sdk.http.HTTPRequest
+import com.nimbusds.oauth2.sdk.http.HTTPResponse
+import com.nimbusds.oauth2.sdk.id.ClientID
+import com.nimbusds.oauth2.sdk.id.Issuer
+import com.nimbusds.oauth2.sdk.token.AccessToken
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken
+import com.nimbusds.oauth2.sdk.token.RefreshToken
+import com.nimbusds.openid.connect.sdk.Nonce
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse
+import com.nimbusds.openid.connect.sdk.SubjectType
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata
+import com.nimbusds.openid.connect.sdk.token.OIDCTokens
+import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import io.jsonwebtoken.Jwts
+import io.jsonwebtoken.SignatureAlgorithm
+import org.apache.commons.lang3.StringUtils
+import org.apache.nifi.registry.properties.NiFiRegistryProperties
+import org.apache.nifi.registry.security.key.Key
+import org.apache.nifi.registry.security.key.KeyService
+import org.apache.nifi.registry.web.security.authentication.jwt.JwtService
+import org.junit.After
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+@RunWith(JUnit4.class)
+class StandardOidcIdentityProviderGroovyTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProviderGroovyTest.class)
+
+ private static final Key SIGNING_KEY = new Key(id: 1, identity: "signingKey", key: "mock-signing-key-value")
+
+ /*
+ Unlike NiFiProperties, NiFiRegistryProperties extends java.util.Properties, which ultimately implements java.util.Map<>, so map coercion cannot be used here. Setting the raw properties does allow for the same outcomes.
+ */
+ private static final Map<String, String> DEFAULT_NIFI_PROPERTIES = [
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_DISCOVERY_URL) : "https://localhost/oidc",
+ (NiFiRegistryProperties.SECURITY_IDENTITY_PROVIDER) : "", // Makes isLoginIdentityProviderEnabled => false
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT) : "1000",
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_READ_TIMEOUT) : "1000",
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_CLIENT_ID) : "expected_client_id",
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_CLIENT_SECRET) : "expected_client_secret",
+ (NiFiRegistryProperties.SECURITY_USER_OIDC_CLAIM_IDENTIFYING_USER): "username"
+ ]
+
+ // Mock collaborators
+ private static NiFiRegistryProperties mockNiFiRegistryProperties
+ private static JwtService mockJwtService = [:] as JwtService
+
+ @BeforeClass
+ static void setUpOnce() throws Exception {
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ mockNiFiRegistryProperties = buildNiFiRegistryProperties()
+ }
+
+ @After
+ void teardown() throws Exception {
+ }
+
+ private static NiFiRegistryProperties buildNiFiRegistryProperties(Map<String, String> props = [:]) {
+ Map<String, String> combinedProps = DEFAULT_NIFI_PROPERTIES + props
+ new NiFiRegistryProperties(combinedProps)
+ }
+
+ private static JwtService buildJwtService() {
+ def mockJS = new JwtService([:] as KeyService) {
+ @Override
+ String generateSignedToken(String identity, String preferredUsername, String issuer, String audience, long expirationMillis) {
+ signNiFiToken(identity, preferredUsername, issuer, audience, expirationMillis)
+ }
+ }
+ mockJS
+ }
+
+ private static String signNiFiToken(String identity, String preferredUsername, String issuer, String audience, long expirationMillis) {
+ String USERNAME_CLAIM = "username"
+ String KEY_ID_CLAIM = "keyId"
+ Calendar expiration = Calendar.getInstance()
+ expiration.setTimeInMillis(System.currentTimeMillis() + 10_000)
+ String username = identity
+
+ return Jwts.builder().setSubject(identity)
+ .setIssuer(issuer)
+ .setAudience(audience)
+ .claim(USERNAME_CLAIM, username)
+ .claim(KEY_ID_CLAIM, SIGNING_KEY.getId())
+ .setExpiration(expiration.getTime())
+ .setIssuedAt(Calendar.getInstance().getTime())
+ .signWith(SignatureAlgorithm.HS256, SIGNING_KEY.key.getBytes("UTF-8")).compact()
+ }
+
+ @Test
+ void testShouldGetAvailableClaims() {
+ // Arrange
+ final Map<String, String> EXPECTED_CLAIMS = [
+ "iss" : "https://accounts.google.com",
+ "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com",
+ "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com",
+ "sub" : "10703475345439756345540",
+ "email" : "person@nifi.apache.org",
+ "email_verified": "true",
+ "at_hash" : "JOGISUDHFiyGHDSFwV5Fah2A",
+ "iat" : "1590022674",
+ "exp" : "1590026274",
+ "empty_claim" : ""
+ ]
+
+ final List<String> POPULATED_CLAIM_NAMES = EXPECTED_CLAIMS.findAll { k, v -> StringUtils.isNotBlank(v) }.keySet().sort()
+
+ JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(EXPECTED_CLAIMS)
+
+ // Act
+ def definedClaims = StandardOidcIdentityProvider.getAvailableClaims(mockJWTClaimsSet)
+ logger.info("Defined claims: ${definedClaims}")
+
+ // Assert
+ assert definedClaims == POPULATED_CLAIM_NAMES
+ }
+
+ @Test
+ void testShouldCreateClientAuthenticationFromPost() {
+ // Arrange
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+
+ soip.oidcProviderMetadata = metadata
+
+ // Set Authorization Method
+ soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_POST]
+ final List<ClientAuthenticationMethod> mockAuthMethod = soip.oidcProviderMetadata["tokenEndpointAuthMethods"]
+ logger.info("Provided Auth Method: ${mockAuthMethod}")
+
+ // Define expected values
+ final ClientID CLIENT_ID = new ClientID("expected_client_id")
+ final Secret CLIENT_SECRET = new Secret("expected_client_secret")
+
+ // Inject into OIP
+ soip.clientId = CLIENT_ID
+ soip.clientSecret = CLIENT_SECRET
+
+ final ClientAuthentication EXPECTED_CLIENT_AUTHENTICATION = new ClientSecretPost(CLIENT_ID, CLIENT_SECRET)
+
+ // Act
+ def clientAuthentication = soip.createClientAuthentication()
+ logger.info("Client Auth properties: ${clientAuthentication.getProperties()}")
+
+ // Assert
+ assert clientAuthentication.getClientID() == EXPECTED_CLIENT_AUTHENTICATION.getClientID()
+ logger.info("Client secret: ${(clientAuthentication as ClientSecretPost).clientSecret.value}")
+ assert ((ClientSecretPost) clientAuthentication).getClientSecret() == ((ClientSecretPost) EXPECTED_CLIENT_AUTHENTICATION).getClientSecret()
+ }
+
+ @Test
+ void testShouldCreateClientAuthenticationFromBasic() {
+ // Arrange
+ // Mock collaborators
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ // Set Auth Method
+ soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
+ final List<ClientAuthenticationMethod> mockAuthMethod = soip.oidcProviderMetadata["tokenEndpointAuthMethods"]
+ logger.info("Provided Auth Method: ${mockAuthMethod}")
+
+ // Define expected values
+ final ClientID CLIENT_ID = new ClientID("expected_client_id")
+ final Secret CLIENT_SECRET = new Secret("expected_client_secret")
+
+ // Inject into OIP
+ soip.clientId = CLIENT_ID
+ soip.clientSecret = CLIENT_SECRET
+
+ final ClientAuthentication EXPECTED_CLIENT_AUTHENTICATION = new ClientSecretBasic(CLIENT_ID, CLIENT_SECRET)
+
+ // Act
+ def clientAuthentication = soip.createClientAuthentication()
+ logger.info("Client authentication properties: ${clientAuthentication.properties}")
+
+ // Assert
+ assert clientAuthentication.getClientID() == EXPECTED_CLIENT_AUTHENTICATION.getClientID()
+ assert clientAuthentication.getMethod() == EXPECTED_CLIENT_AUTHENTICATION.getMethod()
+ logger.info("Client secret: ${(clientAuthentication as ClientSecretBasic).clientSecret.value}")
+ assert (clientAuthentication as ClientSecretBasic).getClientSecret() == EXPECTED_CLIENT_AUTHENTICATION.clientSecret
+ }
+
+ @Test
+ void testShouldCreateTokenHTTPRequest() {
+ // Arrange
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ // Mock AuthorizationGrant
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+ AuthorizationCode mockCode = new AuthorizationCode("ABCDE")
+ def mockAuthGrant = new AuthorizationCodeGrant(mockCode, mockURI)
+
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ // Set OIDC Provider metadata attributes
+ final ClientID CLIENT_ID = new ClientID("expected_client_id")
+ final Secret CLIENT_SECRET = new Secret("expected_client_secret")
+
+ // Inject into OIP
+ soip.clientId = CLIENT_ID
+ soip.clientSecret = CLIENT_SECRET
+ soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
+ soip.oidcProviderMetadata["tokenEndpointURI"] = new URI("https://localhost/token")
+
+ // Mock ClientAuthentication
+ def clientAuthentication = soip.createClientAuthentication()
+
+ // Act
+ def httpRequest = soip.createTokenHTTPRequest(mockAuthGrant, clientAuthentication)
+ logger.info("HTTP Request: ${httpRequest.dump()}")
+ logger.info("Query: ${URLDecoder.decode(httpRequest.query, "UTF-8")}")
+
+ // Assert
+ assert httpRequest.getMethod().name() == "POST"
+ assert httpRequest.query =~ "code=${mockCode.value}"
+ String encodedUri = URLEncoder.encode("https://localhost/oidc", "UTF-8")
+ assert httpRequest.query =~ "redirect_uri=${encodedUri}&grant_type=authorization_code"
+ }
+
+ @Test
+ void testShouldLookupIdentityInUserInfo() {
+ // Arrange
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ final String EXPECTED_IDENTITY = "my_username"
+
+ def responseBody = [username: EXPECTED_IDENTITY, sub: "testSub"]
+ HTTPRequest mockUserInfoRequest = mockHttpRequest(responseBody, 200, "HTTP OK")
+
+ // Act
+ String identity = soip.lookupIdentityInUserInfo(mockUserInfoRequest)
+ logger.info("Identity: ${identity}")
+
+ // Assert
+ assert identity == EXPECTED_IDENTITY
+ }
+
+ @Test
+ void testLookupIdentityUserInfoShouldHandleMissingIdentity() {
+ // Arrange
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ def responseBody = [username: "", sub: "testSub"]
+ HTTPRequest mockUserInfoRequest = mockHttpRequest(responseBody, 200, "HTTP NO USER")
+
+ // Act
+ def msg = shouldFail(IllegalStateException) {
+ String identity = soip.lookupIdentityInUserInfo(mockUserInfoRequest)
+ logger.info("Identity: ${identity}")
+ }
+ logger.expected(msg)
+
+ // Assert
+ assert msg =~ "Unable to extract identity from the UserInfo token using the claim 'username'."
+ }
+
+ @Test
+ void testLookupIdentityUserInfoShouldHandle500() {
+ // Arrange
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJwtService, mockNiFiRegistryProperties)
+
+ Issuer mockIssuer = new Issuer("https://localhost/oidc")
+ URI mockURI = new URI("https://localhost/oidc")
+
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ def errorBody = [error : "Failure to authenticate",
+ error_description: "The provided username and password were not correct",
+ error_uri : "https://localhost/oidc/error"]
+ HTTPRequest mockUserInfoRequest = mockHttpRequest(errorBody, 500, "HTTP ERROR")
+
+ // Act
+ def msg = shouldFail(RuntimeException) {
+ String identity = soip.lookupIdentityInUserInfo(mockUserInfoRequest)
+ logger.info("Identity: ${identity}")
+ }
+ logger.expected(msg)
+
+ // Assert
+ assert msg =~ "An error occurred while invoking the UserInfo endpoint: The provided username and password were not correct"
+ }
+
+ @Test
+ void testShouldConvertOIDCTokenToNiFiToken() {
+ // Arrange
+ StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "email"])
+
+ OIDCTokenResponse mockResponse = mockOIDCTokenResponse()
+ logger.info("OIDC Token Response: ${mockResponse.dump()}")
+
+ // Act
+ String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse)
+ logger.info("NiFi token: ${nifiToken}")
+
+ // Assert
+
+ // Split JWT into components and decode Base64 to JSON
+ def (String headerB64, String payloadB64, String signatureB64) = nifiToken.tokenize("\\.")
+ logger.info("Header: ${headerB64} | Payload: ${payloadB64} | Signature: ${signatureB64}")
+ String headerJson = new String(Base64.decoder.decode(headerB64), "UTF-8")
+ String payloadJson = new String(Base64.decoder.decode(payloadB64), "UTF-8")
+ // String signatureJson = new String(Base64.decoder.decode(signatureB64), "UTF-8")
+
+ // Parse JSON into objects
+ def slurper = new JsonSlurper()
+ def header = slurper.parseText(headerJson)
+ logger.info("Header: ${header}")
+
+ assert header.alg == "HS256"
+
+ def payload = slurper.parseText(payloadJson)
+ logger.info("Payload: ${payload}")
+
+ assert payload.username == "person@nifi.apache.org"
+ assert payload.keyId == "1"
+ assert payload.exp <= System.currentTimeMillis() + 10_000
+ }
+
+ @Test
+ void testConvertOIDCTokenToNiFiTokenShouldHandleBlankIdentity() {
+ // Arrange
+ StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim"])
+
+ OIDCTokenResponse mockResponse = mockOIDCTokenResponse()
+ logger.info("OIDC Token Response: ${mockResponse.dump()}")
+
+ // Act
+ String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse)
+ logger.info("NiFi token: ${nifiToken}")
+
+ // Assert
+ // Split JWT into components and decode Base64 to JSON
+ def (String headerB64, String payloadB64, String signatureB64) = nifiToken.tokenize("\\.")
+ logger.info("Header: ${headerB64} | Payload: ${payloadB64} | Signature: ${signatureB64}")
+ String headerJson = new String(Base64.decoder.decode(headerB64), "UTF-8")
+ String payloadJson = new String(Base64.decoder.decode(payloadB64), "UTF-8")
+ // String signatureJson = new String(Base64.decoder.decode(signatureB64), "UTF-8")
+
+ // Parse JSON into objects
+ def slurper = new JsonSlurper()
+ def header = slurper.parseText(headerJson)
+ logger.info("Header: ${header}")
+
+ assert header.alg == "HS256"
+
+ def payload = slurper.parseText(payloadJson)
+ logger.info("Payload: ${payload}")
+
+ assert payload.username == "person@nifi.apache.org"
+ assert payload.keyId == "1"
+ assert payload.exp <= System.currentTimeMillis() + 10_000
+ }
+
+ @Test
+ void testConvertOIDCTokenToNiFiTokenShouldHandleBlankIdentityAndNoEmailClaim() {
+ // Arrange
+ StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator(["getOidcClaimIdentifyingUser": "non-existent-claim"])
+
+ OIDCTokenResponse mockResponse = mockOIDCTokenResponse(["email": null])
+ logger.info("OIDC Token Response: ${mockResponse.dump()}")
+
+ // Act
+ def msg = shouldFail(ConnectException) {
+ String nifiToken = soip.convertOIDCTokenToNiFiToken(mockResponse)
+ logger.info("NiFi token: ${nifiToken}")
+ }
+
+ // Assert
+ assert msg =~ "Connection refused"
+ }
+
+ @Test
+ void testShouldAuthorizeClient() {
+ // Arrange
+ // Build ID Provider with mock token endpoint URI to make a connection
+ StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:])
+
+ // Mock the JWT
+ def jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY190ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw"
+
+ def responseBody = [id_token: jwt, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"]
+ HTTPRequest mockTokenRequest = mockHttpRequest(responseBody, 200, "HTTP OK")
+
+ // Act
+ def nifiToken = soip.authorizeClient(mockTokenRequest)
+ logger.info("NiFi Token: ${nifiToken.dump()}")
+
+ // Assert
+ assert nifiToken
+ }
+
+ @Test
+ void testAuthorizeClientShouldHandleError() {
+ // Arrange
+ // Build ID Provider with mock token endpoint URI to make a connection
+ StandardOidcIdentityProvider soip = buildIdentityProviderWithMockTokenValidator([:])
+
+ // Mock the JWT
+ def jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik5pRmkgT0lEQyBVbml0IFRlc3RlciIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MzM5MDIyLCJpc3MiOiJuaWZpX3VuaXRfdGVzdF9hdXRob3JpdHkiLCJhdWQiOiJhbGwiLCJ1c2VybmFtZSI6Im9pZGNfdGVzdCIsImVtYWlsIjoib2lkY190ZXN0QG5pZmkuYXBhY2hlLm9yZyJ9.b4NIl0RONKdVLOH0D1eObdwAEX8qX-ExqB8KuKSZFLw"
+
+ def responseBody = [id_token: jwt, access_token: "some.access.token", refresh_token: "some.refresh.token", token_type: "bearer"]
+ HTTPRequest mockTokenRequest = mockHttpRequest(responseBody, 500, "HTTP SERVER ERROR")
+
+ // Act
+ def msg = shouldFail(RuntimeException) {
+ def nifiToken = soip.authorizeClient(mockTokenRequest)
+ logger.info("NiFi token: ${nifiToken}")
+ }
+
+ // Assert
+ assert msg =~ "An error occurred while invoking the Token endpoint: null"
+ }
+
+
+ private StandardOidcIdentityProvider buildIdentityProviderWithMockTokenValidator(Map<String, String> additionalProperties = [:]) {
+ JwtService mockJS = buildJwtService()
+ NiFiRegistryProperties mockNFP = buildNiFiRegistryProperties(additionalProperties)
+ StandardOidcIdentityProvider soip = new StandardOidcIdentityProvider(mockJS, mockNFP)
+
+ // Mock OIDC provider metadata
+ Issuer mockIssuer = new Issuer("mockIssuer")
+ URI mockURI = new URI("https://localhost/oidc")
+ OIDCProviderMetadata metadata = new OIDCProviderMetadata(mockIssuer, [SubjectType.PUBLIC], mockURI)
+ soip.oidcProviderMetadata = metadata
+
+ // Set OIDC Provider metadata attributes
+ final ClientID CLIENT_ID = new ClientID("expected_client_id")
+ final Secret CLIENT_SECRET = new Secret("expected_client_secret")
+
+ // Inject into OIP
+ soip.clientId = CLIENT_ID
+ soip.clientSecret = CLIENT_SECRET
+ soip.oidcProviderMetadata["tokenEndpointAuthMethods"] = [ClientAuthenticationMethod.CLIENT_SECRET_BASIC]
+ soip.oidcProviderMetadata["tokenEndpointURI"] = new URI("https://localhost/oidc/token")
+ soip.oidcProviderMetadata["userInfoEndpointURI"] = new URI("https://localhost/oidc/userInfo")
+
+ // Mock token validator
+ IDTokenValidator mockTokenValidator = new IDTokenValidator(mockIssuer, CLIENT_ID) {
+ @Override
+ IDTokenClaimsSet validate(JWT jwt, Nonce nonce) {
+ return new IDTokenClaimsSet(jwt.getJWTClaimsSet())
+ }
+ }
+ soip.tokenValidator = mockTokenValidator
+ soip
+ }
+
+ private OIDCTokenResponse mockOIDCTokenResponse(Map<String, Object> additionalClaims = [:]) {
+ final Map<String, Object> claims = [
+ "iss" : "https://accounts.google.com",
+ "azp" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com",
+ "aud" : "1013352044499-05pb1ssdfuihsdfsdsdfdi8r2vike88m.apps.googleusercontent.com",
+ "sub" : "10703475345439756345540",
+ "email" : "person@nifi.apache.org",
+ "email_verified": "true",
+ "at_hash" : "JOGISUDHFiyGHDSFwV5Fah2A",
+ "iat" : 1590022674,
+ "exp" : 1590026274
+ ] + additionalClaims
+
+ // Create Claims Set
+ JWTClaimsSet mockJWTClaimsSet = new JWTClaimsSet(claims)
+
+ // Create JWT
+ JWT mockJwt = new PlainJWT(mockJWTClaimsSet)
+
+ // Mock access tokens
+ AccessToken mockAccessToken = new BearerAccessToken()
+ RefreshToken mockRefreshToken = new RefreshToken()
+
+ // Create OIDC Tokens
+ OIDCTokens mockOidcTokens = new OIDCTokens(mockJwt, mockAccessToken, mockRefreshToken)
+
+ // Create OIDC Token Response
+ OIDCTokenResponse mockResponse = new OIDCTokenResponse(mockOidcTokens)
+ mockResponse
+ }
+
+
+ /**
+ * Forms an {@link HTTPRequest} object which returns a static response when {@code send( )} is called.
+ *
+ * @param body the JSON body in Map form
+ * @param statusCode the HTTP status code
+ * @param status the HTTP status message
+ * @param headers an optional map of HTTP response headers
+ * @param method the HTTP method to mock
+ * @param url the endpoint URL
+ * @return the static HTTP response
+ */
+ private static HTTPRequest mockHttpRequest(def body,
+ int statusCode = 200,
+ String status = "HTTP Response",
+ Map<String, String> headers = [:],
+ HTTPRequest.Method method = HTTPRequest.Method.GET,
+ URL url = new URL("https://localhost/oidc")) {
+ new HTTPRequest(method, url) {
+ HTTPResponse send() {
+ HTTPResponse mockResponse = new HTTPResponse(statusCode)
+ mockResponse.setStatusMessage(status)
+ (["Content-Type": "application/json"] + headers).each { String h, String v -> mockResponse.setHeader(h, v) }
+ def responseBody = body
+ mockResponse.setContent(JsonOutput.toJson(responseBody))
+ mockResponse
+ }
+ }
+ }
+
+ class MockOIDCProviderMetadata extends OIDCProviderMetadata {
+
+ MockOIDCProviderMetadata() {
+ super([:] as Issuer, [SubjectType.PUBLIC] as List<SubjectType>, new URI("https://localhost"))
+ }
+ }
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcServiceTest.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcServiceTest.java
new file mode 100644
index 0000000..c3e5701
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/OidcServiceTest.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc;
+
+import com.nimbusds.oauth2.sdk.AuthorizationCode;
+import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
+import com.nimbusds.oauth2.sdk.id.State;
+import org.junit.Test;
+
+import java.net.URI;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class OidcServiceTest {
+
+ public static final String TEST_REQUEST_IDENTIFIER = "test-request-identifier";
+ public static final String TEST_STATE = "test-state";
+
+ @Test(expected = IllegalStateException.class)
+ public void testOidcNotEnabledCreateState() throws Exception {
+ final OidcService service = getServiceWithNoOidcSupport();
+ service.createState(TEST_REQUEST_IDENTIFIER);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testCreateStateMultipleInvocations() throws Exception {
+ final OidcService service = getServiceWithOidcSupport();
+ service.createState(TEST_REQUEST_IDENTIFIER);
+ service.createState(TEST_REQUEST_IDENTIFIER);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testOidcNotEnabledValidateState() throws Exception {
+ final OidcService service = getServiceWithNoOidcSupport();
+ service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE));
+ }
+
+ @Test
+ public void testOidcUnknownState() throws Exception {
+ final OidcService service = getServiceWithOidcSupport();
+ assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, new State(TEST_STATE)));
+ }
+
+ @Test
+ public void testValidateState() throws Exception {
+ final OidcService service = getServiceWithOidcSupport();
+ final State state = service.createState(TEST_REQUEST_IDENTIFIER);
+ assertTrue(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
+ }
+
+ @Test
+ public void testValidateStateExpiration() throws Exception {
+ final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS);
+ final State state = service.createState(TEST_REQUEST_IDENTIFIER);
+
+ Thread.sleep(3 * 1000);
+
+ assertFalse(service.isStateValid(TEST_REQUEST_IDENTIFIER, state));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testOidcNotEnabledExchangeCode() throws Exception {
+ final OidcService service = getServiceWithNoOidcSupport();
+ service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testExchangeCodeMultipleInvocation() throws Exception {
+ final OidcService service = getServiceWithOidcSupport();
+ service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
+ service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testOidcNotEnabledGetJwt() throws Exception {
+ final OidcService service = getServiceWithNoOidcSupport();
+ service.getJwt(TEST_REQUEST_IDENTIFIER);
+ }
+
+ @Test
+ public void testGetJwt() throws Exception {
+ final OidcService service = getServiceWithOidcSupport();
+ service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
+ assertNotNull(service.getJwt(TEST_REQUEST_IDENTIFIER));
+ }
+
+ @Test
+ public void testGetJwtExpiration() throws Exception {
+ final OidcService service = getServiceWithOidcSupportAndCustomExpiration(1, TimeUnit.SECONDS);
+ service.exchangeAuthorizationCode(TEST_REQUEST_IDENTIFIER, getAuthorizationCodeGrant());
+
+ Thread.sleep(3 * 1000);
+
+ assertNull(service.getJwt(TEST_REQUEST_IDENTIFIER));
+ }
+
+ private OidcService getServiceWithNoOidcSupport() {
+ final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
+ when(provider.isOidcEnabled()).thenReturn(false);
+
+ final OidcService service = new OidcService(provider);
+ assertFalse(service.isOidcEnabled());
+
+ return service;
+ }
+
+ private OidcService getServiceWithOidcSupport() throws Exception {
+ final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
+ when(provider.isOidcEnabled()).thenReturn(true);
+ when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString());
+
+ final OidcService service = new OidcService(provider);
+ assertTrue(service.isOidcEnabled());
+
+ return service;
+ }
+
+ private OidcService getServiceWithOidcSupportAndCustomExpiration(final int duration, final TimeUnit units) throws Exception {
+ final OidcIdentityProvider provider = mock(OidcIdentityProvider.class);
+ when(provider.isOidcEnabled()).thenReturn(true);
+ when(provider.exchangeAuthorizationCode(any())).then(invocation -> UUID.randomUUID().toString());
+
+ final OidcService service = new OidcService(provider, duration, units);
+ assertTrue(service.isOidcEnabled());
+
+ return service;
+ }
+
+ private AuthorizationCodeGrant getAuthorizationCodeGrant() {
+ return new AuthorizationCodeGrant(new AuthorizationCode("code"), URI.create("http://localhost:8080/nifi-registry"));
+ }
+}
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderTest.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderTest.java
new file mode 100644
index 0000000..c0b496f
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/security/authentication/oidc/StandardOidcIdentityProviderTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.security.authentication.oidc;
+
+import com.nimbusds.oauth2.sdk.Scope;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.nifi.registry.properties.NiFiRegistryProperties;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class StandardOidcIdentityProviderTest {
+
+ @Test
+ public void testValidateScopes() throws IllegalAccessException {
+ final String additionalScope_profile = "profile";
+ final String additionalScope_abc = "abc";
+
+ final StandardOidcIdentityProvider provider = createOidcProviderWithAdditionalScopes(additionalScope_profile,
+ additionalScope_abc);
+ Scope scope = provider.getScope();
+
+ // two additional scopes are set, two (openid, email) are hard-coded
+ assertEquals(scope.toArray().length, 4);
+ assertTrue(scope.contains("openid"));
+ assertTrue(scope.contains("email"));
+ assertTrue(scope.contains(additionalScope_profile));
+ assertTrue(scope.contains(additionalScope_abc));
+ }
+
+ @Test
+ public void testNoDuplicatedScopes() throws IllegalAccessException {
+ final String additionalScopeDuplicate = "abc";
+
+ final StandardOidcIdentityProvider provider = createOidcProviderWithAdditionalScopes(additionalScopeDuplicate,
+ "def", additionalScopeDuplicate);
+ Scope scope = provider.getScope();
+
+ // three additional scopes are set but one is duplicated and mustn't be returned; note that there is
+ // another one inserted in between the duplicated; two (openid, email) are hard-coded
+ assertEquals(scope.toArray().length, 4);
+ }
+
+ private StandardOidcIdentityProvider createOidcProviderWithAdditionalScopes(String... additionalScopes) throws IllegalAccessException {
+ final StandardOidcIdentityProvider provider = mock(StandardOidcIdentityProvider.class);
+ NiFiRegistryProperties properties = createNiFiPropertiesMockWithAdditionalScopes(Arrays.asList(additionalScopes));
+ Field propertiesField = FieldUtils.getDeclaredField(StandardOidcIdentityProvider.class, "properties", true);
+ propertiesField.set(provider, properties);
+
+ when(provider.isOidcEnabled()).thenReturn(true);
+ when(provider.getScope()).thenCallRealMethod();
+
+ return provider;
+ }
+
+ private NiFiRegistryProperties createNiFiPropertiesMockWithAdditionalScopes(List<String> additionalScopes) {
+ NiFiRegistryProperties properties = mock(NiFiRegistryProperties.class);
+ when(properties.getOidcAdditionalScopes()).thenReturn(additionalScopes);
+ return properties;
+ }
+}
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-ui/pom.xml b/nifi-registry-core/nifi-registry-web-ui/pom.xml
index 30f078e..e79b93a 100644
--- a/nifi-registry-core/nifi-registry-web-ui/pom.xml
+++ b/nifi-registry-core/nifi-registry-web-ui/pom.xml
@@ -474,5 +474,9 @@
<version>3.3.0</version>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>javax.servlet</groupId>
+ <artifactId>javax.servlet-api</artifactId>
+ </dependency>
</dependencies>
</project>
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LoginFilter.java b/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LoginFilter.java
new file mode 100644
index 0000000..3bc25ff
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LoginFilter.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.filter;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * Filter for determining appropriate login location.
+ */
+public class LoginFilter implements Filter {
+
+ private ServletContext servletContext;
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ servletContext = filterConfig.getServletContext();
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
+ final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
+
+ if (supportsOidc) {
+ final ServletContext apiContext = servletContext.getContext("/nifi-registry-api");
+ apiContext.getRequestDispatcher("/access/oidc/request").forward(request, response);
+
+ } else {
+ // Forward the client to the default login page for basic credential login
+ final HttpServletResponse httpResponse = (HttpServletResponse) response;
+ httpResponse.sendRedirect("/nifi-registry/#/login");
+
+ }
+ }
+
+ @Override
+ public void destroy() {
+ }
+}
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LogoutFilter.java b/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LogoutFilter.java
new file mode 100644
index 0000000..a005d5d
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/java/org/apache/nifi/registry/web/filter/LogoutFilter.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.registry.web.filter;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import java.io.IOException;
+
+/**
+ * Filter for determining appropriate logout location.
+ */
+public class LogoutFilter implements Filter {
+
+ private ServletContext servletContext;
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ servletContext = filterConfig.getServletContext();
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
+ final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
+
+ if (supportsOidc) {
+ final ServletContext apiContext = servletContext.getContext("/nifi-registry-api");
+ apiContext.getRequestDispatcher("/access/oidc/logout").forward(request, response);
+ } else {
+ final ServletContext apiContext = servletContext.getContext("/nifi-registry-api");
+ apiContext.getRequestDispatcher("/access/logout").forward(request, response);
+ }
+ }
+
+ @Override
+ public void destroy() {
+ }
+}
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/WEB-INF/web.xml b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/WEB-INF/web.xml
index d3eceef..1af908d 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/WEB-INF/web.xml
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/WEB-INF/web.xml
@@ -49,5 +49,25 @@
<welcome-file-list>
<welcome-file>index.html</welcome-file>
</welcome-file-list>
+
+ <!-- login filter -->
+ <filter>
+ <filter-name>LoginFilter</filter-name>
+ <filter-class>org.apache.nifi.registry.web.filter.LoginFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>LoginFilter</filter-name>
+ <url-pattern>/login</url-pattern>
+ </filter-mapping>
+
+ <!-- logout filter -->
+ <filter>
+ <filter-name>LogoutFilter</filter-name>
+ <filter-class>org.apache.nifi.registry.web.filter.LogoutFilter</filter-class>
+ </filter>
+ <filter-mapping>
+ <filter-name>LogoutFilter</filter-name>
+ <url-pattern>/logout</url-pattern>
+ </filter-mapping>
</web-app>
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.html b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.html
index b2aff5a..97f2ac0 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.html
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.html
@@ -74,7 +74,7 @@
<div *ngIf="nfRegistryService.currentUser.identity && nfRegistryService.perspective !== 'login' && nfRegistryService.perspective !== 'not-found'" fxLayout="column" fxLayoutAlign="space-around end" class="push-right-sm">
<div id="current-user" matTooltip="{{nfRegistryService.currentUser.identity}}">{{nfRegistryService.currentUser.identity}}</div>
<a id="logout-link-container" *ngIf="nfRegistryService.currentUser.canLogout" class="link" (click)="logout()">logout</a>
- <a id="login-link-container" *ngIf="!nfRegistryService.currentUser.canLogout && nfRegistryService.currentUser.anonymous && nfRegistryService.currentUser.loginSupported" class="link" (click)="login()">login</a>
+ <a id="login-link-container" *ngIf="!nfRegistryService.currentUser.canLogout && nfRegistryService.currentUser.anonymous && (nfRegistryService.currentUser.loginSupported || nfRegistryService.currentUser.oidcloginSupported)" class="link" (click)="login()">login</a>
</div>
<div id="nifi-registry-documentation" *ngIf="nfRegistryService.perspective !== 'login'" class="pad-right-sm">
<a matTooltip="Help" href="{{nfRegistryService.documentation.link}}" target="_blank"><i class="fa fa-question-circle help-icon" aria-hidden="true"></i></a>
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.js b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.js
index 79e97a8..1309327 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.js
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/nf-registry.js
@@ -72,7 +72,7 @@
*/
logout: function () {
var self = this;
- self.nfRegistryApi.deleteToLogout().subscribe(
+ self.nfRegistryApi.deleteToLogout('../nifi-registry/logout').subscribe(
function () {
// next call
},
@@ -93,7 +93,12 @@
* Navigate to login route.
*/
login: function () {
- this.router.navigateByUrl('login');
+ var self = this;
+ if (self.nfRegistryService.currentUser.oidcloginSupported === true) {
+ window.location.href = location.origin + '/nifi-registry/login';
+ } else {
+ self.router.navigateByUrl('login');
+ }
}
};
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.js b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.js
index ea2ba22..99cacf8 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.js
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.js
@@ -27,7 +27,8 @@
var config = {
urls: {
currentUser: '../nifi-registry-api/access',
- kerberos: '../nifi-registry-api/access/token/kerberos'
+ kerberos: '../nifi-registry-api/access/token/kerberos',
+ oidc: '../nifi-registry-api/access/oidc/exchange'
}
};
@@ -730,7 +731,7 @@
*
* @returns {*}
*/
- deleteToLogout: function () {
+ deleteToLogout: function (url) {
var self = this;
var options = {
headers: headers,
@@ -738,7 +739,7 @@
responseType: 'text'
};
- return this.http.delete('../nifi-registry-api/access/logout', options).pipe(
+ return this.http.delete(url, options).pipe(
map(function (response) {
return response;
}),
@@ -755,7 +756,7 @@
},
/**
- * Kerberos ticket exchange.
+ * Kerberos and OIDC ticket exchange.
*
* @returns {*}
*/
@@ -764,18 +765,28 @@
if (this.nfStorage.hasItem('jwt')) {
return of(self.nfStorage.getItem('jwt'));
}
+ var jwtHandler = function (jwt) {
+ // get the payload and store the token with the appropriate expiration
+ var token = self.nfStorage.getJwtPayload(jwt);
+ if (token) {
+ var expiration = parseInt(token['exp'], 10) * MILLIS_PER_SECOND;
+ self.nfStorage.setItem('jwt', jwt, expiration);
+ }
+ return jwt;
+ };
return this.http.post(config.urls.kerberos, null, {responseType: 'text'}).pipe(
map(function (jwt) {
- // get the payload and store the token with the appropriate expiration
- var token = self.nfStorage.getJwtPayload(jwt);
- if (token) {
- var expiration = parseInt(token['exp'], 10) * MILLIS_PER_SECOND;
- self.nfStorage.setItem('jwt', jwt, expiration);
- }
- return jwt;
+ return jwtHandler(jwt);
}),
catchError(function (error) {
- return of('');
+ return self.http.post(config.urls.oidc, null, {responseType: 'text', withCredentials: 'true'}).pipe(
+ map(function (jwt) {
+ return jwtHandler(jwt);
+ }),
+ catchError(function (error) {
+ return of('');
+ })
+ );
})
);
},
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.spec.js b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.spec.js
index b27ee11..bd942c2 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.spec.js
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.api.spec.js
@@ -27,9 +27,14 @@
} from 'services/nf-registry.auth-guard.service';
import { of } from 'rxjs';
+var kerbUrl = '../nifi-registry-api/access/token/kerberos';
+var oidcUrl = '../nifi-registry-api/access/oidc/exchange';
+var tokenVal = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJiYmVuZGVATklGSS5BUEFDSEUuT1JHIiwiaXNzIjoiS2VyYmVyb3NTcG5lZ29JZGVudGl0eVByb3ZpZGVyIiwiYXVkIjoiS2VyYmVyb3NTcG5lZ29JZGVudGl0eVByb3ZpZGVyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYmJlbmRlQE5JRkkuQVBBQ0hFLk9SRyIsImtpZCI6IjQ3NWQwZWEyLTkzZGItNDhiNi05MjcxLTgyOGM3MzQ5ZTFkNiIsImlhdCI6MTUxMjQ4NTY4NywiZXhwIjoxNTEyNTI4ODg3fQ.lkaWPQw1ld7Qqb6-Zu8mAqu6r8mUVHBNP0ZfNpES3rA';
+
describe('NfRegistry API w/ Angular testing utils', function () {
let nfRegistryApi;
let req;
+ let reqAgain;
const providers = [
NfRegistryUsersAdministrationAuthGuard,
@@ -94,11 +99,74 @@
});
// the request it made
- req = httpMock.expectOne('../nifi-registry-api/access/token/kerberos');
+ req = httpMock.expectOne(kerbUrl);
expect(req.request.method).toEqual('POST');
// Next, fulfill the request by transmitting a response.
req.flush(null, {status: 401, statusText: 'POST exchange tickets mock error'});
+ reqAgain = httpMock.expectOne(oidcUrl);
+ reqAgain.flush(null, {status: 401, statusText: 'POST exchange tickets mock error'});
+
+ // Finally, assert that there are no outstanding requests.
+ httpMock.verify();
+ }));
+
+ it('ticketExchange should POST to Kerberos, fail, and then use the OIDC endpoint to retrieve a JWT.', inject([HttpTestingController], function (httpMock) {
+ // Spy
+ spyOn(nfRegistryApi.nfStorage, 'setItem').and.callThrough();
+
+ // api call
+ nfRegistryApi.ticketExchange().subscribe(function (response) {
+ var setItemCall = nfRegistryApi.nfStorage.setItem.calls.first();
+
+ expect(setItemCall.args[1]).toBe(tokenVal);
+ expect(response).toBe(tokenVal);
+ });
+
+ req = httpMock.expectOne(kerbUrl);
+ req.flush(null, {status: 401, statusText: 'POST exchange tickets mock error'});
+ reqAgain = httpMock.expectOne(oidcUrl);
+ reqAgain.flush(tokenVal);
+
+ // Finally, assert that there are no outstanding requests.
+ httpMock.verify();
+ }));
+
+ it('ticketExchange should POST to Kerberos and OIDC endpoints and fail to retrieve a JWT.', inject([HttpTestingController], function (httpMock) {
+ // Spy
+ spyOn(nfRegistryApi.nfStorage, 'setItem').and.callThrough();
+
+ // api call
+ nfRegistryApi.ticketExchange().subscribe(
+ function (response) {
+ expect(response).toBe('');
+ }
+ );
+
+ req = httpMock.expectOne(kerbUrl);
+ req.flush(null, {status: 401, statusText: 'POST exchange tickets mock error'});
+ reqAgain = httpMock.expectOne(oidcUrl);
+ reqAgain.flush(null, {status: 401, statusText: 'POST exchange tickets mock error'});
+
+ // Finally, assert that there are no outstanding requests.
+ httpMock.verify();
+ }));
+
+ it('ticketExchange should POST to Kerberos to retrieve a JWT.', inject([HttpTestingController], function (httpMock) {
+ // Spy
+ spyOn(nfRegistryApi.nfStorage, 'setItem').and.callThrough();
+
+ // api call
+ nfRegistryApi.ticketExchange().subscribe(function (response) {
+ console.log('ticketExchange() response is: '.concat(response));
+ var setItemCall = nfRegistryApi.nfStorage.setItem.calls.first();
+
+ expect(setItemCall.args[1]).toBe(tokenVal);
+ expect(response).toBe(tokenVal);
+ });
+
+ req = httpMock.expectOne(kerbUrl);
+ req.flush(tokenVal);
// Finally, assert that there are no outstanding requests.
httpMock.verify();
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.auth-guard.service.js b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.auth-guard.service.js
index e04bf4e..f3826eb 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.auth-guard.service.js
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.auth-guard.service.js
@@ -63,7 +63,7 @@
// Store the attempted URL for redirecting
this.nfRegistryService.redirectUrl = url;
- // attempt kerberos authentication
+ // attempt Kerberos or OIDC authentication
this.nfRegistryApi.ticketExchange().subscribe(function (jwt) {
self.nfRegistryApi.loadCurrentUser().subscribe(function (currentUser) {
// there is no anonymous access and we don't know this user - open the login page which handles login/registration/etc
@@ -186,7 +186,7 @@
// Store the attempted URL for redirecting
this.nfRegistryService.redirectUrl = url;
- // attempt kerberos authentication
+ // attempt Kerberos or OIDC authentication
this.nfRegistryApi.ticketExchange().subscribe(function (jwt) {
self.nfRegistryApi.loadCurrentUser().subscribe(function (currentUser) {
// there is no anonymous access and we don't know this user - open the login page which handles login/registration/etc
@@ -296,7 +296,7 @@
resolve(true);
return;
}
- // attempt kerberos authentication
+ // attempt Kerberos or OIDC authentication
this.nfRegistryApi.ticketExchange().subscribe(function (jwt) {
self.nfRegistryApi.loadCurrentUser().subscribe(function (currentUser) {
self.nfRegistryService.currentUser = currentUser;
@@ -367,7 +367,7 @@
// Store the attempted URL for redirecting
this.nfRegistryService.redirectUrl = url;
- // attempt kerberos authentication
+ // attempt Kerberos or OIDC authentication
this.nfRegistryApi.ticketExchange().subscribe(function (jwt) {
self.nfRegistryApi.loadCurrentUser().subscribe(function (currentUser) {
// there is no anonymous access and we don't know this user - open the login page which handles login/registration/etc
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.service.js b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.service.js
index c0c65d7..8e1241b 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.service.js
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/services/nf-registry.service.js
@@ -206,6 +206,7 @@
this.explorerViewType = '';
this.currentUser = {
loginSupported: false,
+ oidcloginSupported: false,
resourcePermissions: {
anyTopLevelResource: {
canRead: false,
diff --git a/nifi-registry-core/pom.xml b/nifi-registry-core/pom.xml
index 4301aa1..6428c88 100644
--- a/nifi-registry-core/pom.xml
+++ b/nifi-registry-core/pom.xml
@@ -143,6 +143,22 @@
<artifactId>jersey-media-multipart</artifactId>
<version>${jersey.server.version}</version>
</dependency>
+ <!-- open id connect - override transitive dependency version ranges -->
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ <version>6.16.2</version>
+ </dependency>
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>nimbus-jose-jwt</artifactId>
+ <version>8.20</version>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <version>28.0-jre</version>
+ </dependency>
</dependencies>
</dependencyManagement>
diff --git a/pom.xml b/pom.xml
index eaca791..bbbead1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -91,7 +91,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<inceptionYear>2017</inceptionYear>
- <org.slf4j.version>1.7.12</org.slf4j.version>
+ <org.slf4j.version>1.7.30</org.slf4j.version>
<jetty.version>9.4.19.v20190610</jetty.version>
<jax.rs.api.version>2.1</jax.rs.api.version>
<jersey.server.version>2.29.1</jersey.server.version>