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>