NIFI-7332 Added method to log available claim names from the ID provider response when the OIDC Identifying User claim is not found. Revised log message to print available claims.
Added new StandardOidcIdentityProviderGroovyTest file.
Updated deprecated methods in StandardOidcIdentityProvider. Changed log output to print all available claim names from JWTClaimsSet. Added unit test.
Added comments in getAvailableClaims() method.
Fixed typos in NiFi Docs Admin Guide.
Added license to Groovy test.
Fixed a checkstyle error.
Refactor exchangeAuthorizationCode method.
Added unit tests.
Verified all unit tests added so far are passing.
Refactored code. Added unit tests.
Refactored OIDC provider to decouple constructor & network-dependent initialization.
Added unit tests.
Added unit tests.
Refactored OIDC provider to separately authorize the client. Added unit tests.
Added unit tests.

NIFI-7332 Refactored exchangeAuthorizationCode method to separately retrieve the NiFi JWT.

Signed-off-by: Nathan Gough <thenatog@gmail.com>

This closes #4344.
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index b2653d2..90daf3a 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -367,10 +367,9 @@
 |`nifi.security.user.oidc.read.timeout` | Read timeout when communicating with the OpenId Connect Provider.
 |`nifi.security.user.oidc.client.id` | The client id for NiFi after registration with the OpenId Connect Provider.
 |`nifi.security.user.oidc.client.secret` | The client secret for NiFi after registration with the OpenId Connect Provider.
-|`nifi.security.user.oidc.preferred.jwsalgorithm` | The preferred algorithm for for validating identity tokens. If this value is blank, it will default to `RS256` which is required to be supported
+|`nifi.security.user.oidc.preferred.jwsalgorithm` | The preferred algorithm for validating identity tokens. If this value is blank, it will default to `RS256` which is required to be supported
 |`nifi.security.user.oidc.additional.scopes` | Comma separated scopes that are sent to OpenId Connect Provider in addition to `openid` and `email`.
-|`nifi.security.user.oidc.claim.identifying.user` | Claim that identifies the user to be logged in; default is `email`. May need to be requested via the `nifi.security.user.oidc.additional.scopes` before usage.
-by the OpenId Connect Provider according to the specification. If this value is `HS256`, `HS384`, or `HS512`, NiFi will attempt to validate HMAC protected tokens using the specified client secret.
+|`nifi.security.user.oidc.claim.identifying.user` | Claim that identifies the user to be logged in; default is `email`. May need to be requested via the `nifi.security.user.oidc.additional.scopes` before usage by the OpenId Connect Provider according to the specification. If this value is `HS256`, `HS384`, or `HS512`, NiFi will attempt to validate HMAC protected tokens using the specified client secret.
 If this value is `none`, NiFi will attempt to validate unsecured/plain tokens. Other values for this algorithm will attempt to parse as an RSA or EC algorithm to be used in conjunction with the
 JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the discovery URL.
 |==================================================================================================================================================
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java
index f51be91..cecd792 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcIdentityProvider.java
@@ -20,7 +20,6 @@
 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;
 
@@ -29,6 +28,11 @@
     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
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java
index 4b0ec7c..b749085 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/OidcService.java
@@ -21,8 +21,6 @@
 import com.nimbusds.oauth2.sdk.AuthorizationGrant;
 import com.nimbusds.oauth2.sdk.Scope;
 import com.nimbusds.oauth2.sdk.id.State;
-import org.apache.nifi.web.security.util.CacheKey;
-
 import java.io.IOException;
 import java.math.BigInteger;
 import java.net.URI;
@@ -31,6 +29,7 @@
 import java.security.SecureRandom;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
+import org.apache.nifi.web.security.util.CacheKey;
 
 import static org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED;
 
@@ -66,6 +65,7 @@
             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();
@@ -198,7 +198,7 @@
         }
 
         final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
-        final String nifiJwt = identityProvider.exchangeAuthorizationCode(authorizationGrant);
+        final String nifiJwt = retrieveNifiJwt(authorizationGrant);
 
         try {
             // cache the jwt for later retrieval
@@ -214,6 +214,17 @@
     }
 
     /**
+     * Exchange the authorization code to retrieve a NiFi JWT.
+     *
+     * @param authorizationGrant authorization grant
+     * @return NiFi JWT
+     * @throws IOException exceptional case for communication error with the OpenId Connect provider
+     */
+    public String retrieveNifiJwt(final AuthorizationGrant authorizationGrant) throws IOException {
+        return identityProvider.exchangeAuthorizationCode(authorizationGrant);
+    }
+
+    /**
      * 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.
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java
index d7b7886..f7b54f1 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/oidc/StandardOidcIdentityProvider.java
@@ -25,6 +25,7 @@
 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;
@@ -55,9 +56,12 @@
 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 net.minidev.json.JSONObject;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.authentication.exception.IdentityAccessException;
 import org.apache.nifi.util.FormatUtils;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.security.jwt.JwtService;
@@ -72,6 +76,7 @@
 public class StandardOidcIdentityProvider implements OidcIdentityProvider {
 
     private static final Logger logger = LoggerFactory.getLogger(StandardOidcIdentityProvider.class);
+    private final String EMAIL_CLAIM = "email";
 
     private NiFiProperties properties;
     private JwtService jwtService;
@@ -91,114 +96,146 @@
     public StandardOidcIdentityProvider(final JwtService jwtService, final NiFiProperties properties) {
         this.properties = properties;
         this.jwtService = jwtService;
+    }
 
+    /**
+     * Loads OIDC configuration values from {@link NiFiProperties}, 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()) {
-            if (properties.isLoginIdentityProviderEnabled() || properties.isKnoxSsoEnabled()) {
-                throw new RuntimeException("OpenId Connect support cannot be enabled if the Login Identity Provider or Apache Knox SSO is configured.");
-            }
+        if (!properties.isOidcEnabled()) {
+            logger.warn("The OIDC provider is not configured or enabled");
+            return;
+        }
 
-            // oidc connect timeout
-            final String rawConnectTimeout = properties.getOidcConnectTimeout();
-            try {
-                oidcConnectTimeout = (int) FormatUtils.getTimeDuration(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 '{}'",
-                        NiFiProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT, rawConnectTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
-                oidcConnectTimeout = (int) FormatUtils.getTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS);
-            }
+        validateOIDCConfiguration();
 
-            // oidc read timeout
-            final String rawReadTimeout = properties.getOidcReadTimeout();
-            try {
-                oidcReadTimeout = (int) FormatUtils.getTimeDuration(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 '{}'",
-                        NiFiProperties.SECURITY_USER_OIDC_READ_TIMEOUT, rawReadTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
-                oidcReadTimeout = (int) FormatUtils.getTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT, TimeUnit.MILLISECONDS);
-            }
+        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);
+        }
 
-            // 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);
+        validateOIDCProviderMetadata();
+    }
 
-            // 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);
+    /**
+     * 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.");
+        }
 
-            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);
-            }
+        // 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 authorization endpoint is present
-            if (oidcProviderMetadata.getAuthorizationEndpointURI() == null) {
-                throw new RuntimeException("OpenId Connect Provider metadata does not contain an Authorization 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()));
+        }
 
-            // ensure the token endpoint is present
-            if (oidcProviderMetadata.getTokenEndpointURI() == null) {
-                throw new RuntimeException("OpenId Connect Provider metadata does not contain a Token Endpoint.");
-            }
+        // 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.");
+        }
 
-            // 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()));
-            }
+        try {
+            // get the preferred json web signature algorithm
+            final String rawPreferredJwsAlgorithm = properties.getOidcPreferredJwsAlgorithm();
 
-            // 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;
+            final JWSAlgorithm preferredJwsAlgorithm;
+            if (StringUtils.isBlank(rawPreferredJwsAlgorithm)) {
+                preferredJwsAlgorithm = JWSAlgorithm.RS256;
+            } else {
+                if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) {
+                    preferredJwsAlgorithm = null;
                 } else {
-                    if ("none".equalsIgnoreCase(rawPreferredJwsAlgorithm)) {
-                        preferredJwsAlgorithm = null;
-                    } else {
-                        preferredJwsAlgorithm = JWSAlgorithm.parse(rawPreferredJwsAlgorithm);
-                    }
+                    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);
             }
+
+            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 NiFiProperties} and populates the individual fields.
+     */
+    private void validateOIDCConfiguration() {
+        if (properties.isLoginIdentityProviderEnabled() || properties.isKnoxSsoEnabled()) {
+            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 '{}'",
+                    NiFiProperties.SECURITY_USER_OIDC_CONNECT_TIMEOUT, rawConnectTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_CONNECT_TIMEOUT);
+            oidcConnectTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.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 '{}'",
+                    NiFiProperties.SECURITY_USER_OIDC_READ_TIMEOUT, rawReadTimeout, NiFiProperties.DEFAULT_SECURITY_USER_OIDC_READ_TIMEOUT);
+            oidcReadTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.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);
@@ -243,7 +280,7 @@
             throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED);
         }
 
-        Scope scope = new Scope("openid", "email");
+        Scope scope = new Scope("openid", EMAIL_CLAIM);
 
         for (String additionalScope : properties.getOidcAdditionalScopes()) {
             // Scope automatically prevents duplicated entries
@@ -264,81 +301,137 @@
 
     @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);
         }
 
-        final ClientAuthentication clientAuthentication;
-        if (oidcProviderMetadata.getTokenEndpointAuthMethods().contains(ClientAuthenticationMethod.CLIENT_SECRET_POST)) {
-            clientAuthentication = new ClientSecretPost(clientId, clientSecret);
-        } else {
-            clientAuthentication = new ClientSecretBasic(clientId, clientSecret);
-        }
+        // Build ClientAuthentication
+        final ClientAuthentication clientAuthentication = createClientAuthentication();
 
         try {
-            // build the token request
-            final TokenRequest request = new TokenRequest(oidcProviderMetadata.getTokenEndpointURI(), clientAuthentication, authorizationGrant);
-            final HTTPRequest tokenHttpRequest = request.toHTTPRequest();
-            tokenHttpRequest.setConnectTimeout(oidcConnectTimeout);
-            tokenHttpRequest.setReadTimeout(oidcReadTimeout);
+            // Build the token request
+            final HTTPRequest tokenHttpRequest = createTokenHTTPRequest(authorizationGrant, clientAuthentication);
+            return authorizeClient(tokenHttpRequest);
 
-            // get the token response
-            final TokenResponse response = OIDCTokenResponseParser.parse(tokenHttpRequest.send());
-
-            if (response.indicatesSuccess()) {
-                final OIDCTokenResponse 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 identity = claimsSet.getStringClaim(properties.getOidcClaimIdentifyingUser());
-                if (StringUtils.isBlank(identity)) {
-                    // explicitly try to get the identity from the UserInfo endpoint with the configured claim
-                    logger.warn("Failed to obtain the identity of the user with the claim '" +
-                            properties.getOidcClaimIdentifyingUser() + "'. The claim is configured incorrectly. Will attempt to obtain the identity from the UserInfo endpoint.");
-
-                    // 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
-                    identity = lookupIdentityInUserInfo(bearerAccessToken);
-                }
-
-                // extract expiration details from the claims set
-                final Calendar now = Calendar.getInstance();
-                final Date expiration = claimsSet.getExpirationTime();
-                final long expiresIn = expiration.getTime() - now.getTimeInMillis();
-
-                // convert into a nifi jwt for retrieval later
-                final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(identity, identity, expiresIn,
-                        claimsSet.getIssuer().getValue());
-                return jwtService.generateSignedToken(loginToken);
-            } else {
-                final TokenErrorResponse errorResponse = (TokenErrorResponse) response;
-                throw new RuntimeException("An error occurred while invoking the Token endpoint: " +
-                        errorResponse.getErrorObject().getDescription());
-            }
-        } catch (final ParseException | JOSEException | BadJOSEException e) {
+        } 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 lookupIdentityInUserInfo(final BearerAccessToken bearerAccessToken) throws IOException {
-        try {
-            // build the user request
-            final UserInfoRequest request = new UserInfoRequest(oidcProviderMetadata.getUserInfoEndpointURI(), bearerAccessToken);
-            final HTTPRequest tokenHttpRequest = request.toHTTPRequest();
-            tokenHttpRequest.setConnectTimeout(oidcConnectTimeout);
-            tokenHttpRequest.setReadTimeout(oidcReadTimeout);
+    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();
+
+        // convert into a nifi jwt for retrieval later
+        final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(identity, identity, expiresIn,
+                claimsSet.getIssuer().getValue());
+        return jwtService.generateSignedToken(loginToken);
+    }
+
+    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(request.toHTTPRequest().send());
+            final UserInfoResponse response = UserInfoResponse.parse(userInfoHttpRequest.send());
 
             // interpret the details
             if (response.indicatesSuccess()) {
@@ -362,10 +455,10 @@
                 }
             } else {
                 final UserInfoErrorResponse errorResponse = (UserInfoErrorResponse) response;
-                throw new RuntimeException("An error occurred while invoking the UserInfo endpoint: " + errorResponse.getErrorObject().getDescription());
+                throw new IdentityAccessException("An error occurred while invoking the UserInfo endpoint: " + errorResponse.getErrorObject().getDescription());
             }
         } catch (final ParseException | java.text.ParseException e) {
-            throw new RuntimeException("Unable to parse the response from the UserInfo token request: " + e.getMessage());
+            throw new IdentityAccessException("Unable to parse the response from the UserInfo token request: " + e.getMessage());
         }
     }
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy
new file mode 100644
index 0000000..2b1e2a2
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/groovy/org/apache/nifi/web/security/oidc/StandardOidcIdentityProviderGroovyTest.groovy
@@ -0,0 +1,584 @@
+/*
+ * 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.web.security.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.nifi.admin.service.KeyService
+import org.apache.nifi.key.Key
+import org.apache.nifi.util.NiFiProperties
+import org.apache.nifi.util.StringUtils
+import org.apache.nifi.web.security.jwt.JwtService
+import org.apache.nifi.web.security.token.LoginAuthenticationToken
+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")
+    private static final Map<String, Object> DEFAULT_NIFI_PROPERTIES = [
+            isOidcEnabled                 : false,
+            getOidcDiscoveryUrl           : "https://localhost/oidc",
+            isLoginIdentityProviderEnabled: false,
+            isKnoxSsoEnabled              : false,
+            getOidcConnectTimeout         : 1000,
+            getOidcReadTimeout            : 1000,
+            getOidcClientId               : "expected_client_id",
+            getOidcClientSecret           : "expected_client_secret",
+            getOidcClaimIdentifyingUser   : "username"
+    ]
+
+    // Mock collaborators
+    private static NiFiProperties mockNiFiProperties
+    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 {
+        mockNiFiProperties = buildNiFiProperties()
+    }
+
+    @After
+    void teardown() throws Exception {
+    }
+
+    private static NiFiProperties buildNiFiProperties(Map<String, Object> props = [:]) {
+        def combinedProps = DEFAULT_NIFI_PROPERTIES + props
+        def mockNFP = combinedProps.collectEntries { String k, def v ->
+            [k, { -> return v }]
+        }
+        mockNFP as NiFiProperties
+    }
+
+    private static JwtService buildJwtService() {
+        def mockJS = new JwtService([:] as KeyService) {
+            @Override
+            String generateSignedToken(LoginAuthenticationToken lat) {
+                signNiFiToken(lat)
+            }
+
+        }
+        mockJS
+    }
+
+    private static String signNiFiToken(LoginAuthenticationToken lat) {
+        String identity = "mockUser"
+        String USERNAME_CLAIM = "username"
+        String KEY_ID_CLAIM = "keyId"
+        Calendar expiration = Calendar.getInstance()
+        expiration.setTimeInMillis(System.currentTimeMillis() + 10_000)
+        String username = lat.getName()
+
+        return Jwts.builder().setSubject(identity)
+                .setIssuer(lat.getIssuer())
+                .setAudience(lat.getIssuer())
+                .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, mockNiFiProperties)
+
+        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, mockNiFiProperties)
+
+        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, mockNiFiProperties)
+
+        // 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, mockNiFiProperties)
+
+        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, mockNiFiProperties)
+
+        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, mockNiFiProperties)
+
+        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()
+        NiFiProperties mockNFP = buildNiFiProperties(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"))
+        }
+    }
+}