You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by mc...@apache.org on 2020/11/18 17:46:25 UTC

[nifi] branch main updated: NIFI-7888 Added support for authenticating via SAML - Add dependency on spring-security-saml2-core - Updated AccessResource with new SAML end-points - Updated Login/Logout filters to handle SAML scenario - Updated logout process to track a logout request using a cookie - Added database storage for cached SAML credential and user groups - Updated proxied requests when clustered to send IDP groups in a header - Updated X509 filter to process the IDP groups from the header if present - Update [...]

This is an automated email from the ASF dual-hosted git repository.

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new dcc4fb0  NIFI-7888 Added support for authenticating via SAML - Add dependency on spring-security-saml2-core - Updated AccessResource with new SAML end-points - Updated Login/Logout filters to handle SAML scenario - Updated logout process to track a logout request using a cookie - Added database storage for cached SAML credential and user groups - Updated proxied requests when clustered to send IDP groups in a header - Updated X509 filter to process the IDP groups from the header  [...]
dcc4fb0 is described below

commit dcc4fb00a51ac4f5798a39a43d8033bb1b65a306
Author: Bryan Bende <bb...@apache.org>
AuthorDate: Tue Sep 15 15:53:32 2020 -0400

    NIFI-7888 Added support for authenticating via SAML
    - Add dependency on spring-security-saml2-core
    - Updated AccessResource with new SAML end-points
    - Updated Login/Logout filters to handle SAML scenario
    - Updated logout process to track a logout request using a cookie
    - Added database storage for cached SAML credential and user groups
    - Updated proxied requests when clustered to send IDP groups in a header
    - Updated X509 filter to process the IDP groups from the header if present
    - Updated admin guide
    - Fixed logout action on error page
    
    - Updated UserGroupProvider with a default method for getGroupByName
    - Updated StandardManagedAuthorizer to combine groups from request with groups from lookup
    - Updated UserGroupProvider implementations with more efficient impl of getGroupByName
    - Added/updated unit tests
    
    - Ensure signing algorithm is applied to all signatures and not just metadata signatures
    - Added property to specify signature digest algorithm
    
    - Added option to specify whether JDK truststore or NiFi's truststore should be used when connecting to IDP over https
    - Added properties to configure connect and read timeouts for http client
    
    - Added URL encoding of issuer when generating JWT to prevent potential issue with the frontend performing base64 decoding
    
    - Made atomic replace methods for storing groups and saml credential in database
    
    - Added properties to control AuthnRequestsSigned and WantAssertionsSigned in the generated service provider metadata
    
    - Dynamically determine the private key alias from the keystore and remove the property for specifying the signing key alias
    
    - Fixed unit test
    
    - Added property to specify an optional identity attribute which would be used instead of NameID
    
    - Cleaned up logging
    
    - Fallback to keystore password when key password is blank
    
    - Make signature and digest default to SHA-256 when no value provided in nifi.properties
    
    This closes #4614
---
 .../java/org/apache/nifi/util/NiFiProperties.java  | 187 ++++-
 .../src/main/asciidoc/administration-guide.adoc    |  33 +-
 .../AbstractPolicyBasedAuthorizer.java             |  43 +-
 .../nifi/authorization/UserGroupProvider.java      |  24 +
 .../nifi/authorization/resource/Authorizable.java  |   4 +-
 .../apache/nifi/authorization/user/NiFiUser.java   |  12 +
 .../authorization/MockPolicyBasedAuthorizer.java   |   5 +
 .../nifi/admin/IdpDataSourceFactoryBean.java       | 164 +++++
 .../java/org/apache/nifi/admin/dao/DAOFactory.java |   5 +
 .../dao/{DAOFactory.java => IdpCredentialDAO.java} |  18 +-
 .../dao/{DAOFactory.java => IdpUserGroupDAO.java}  |  22 +-
 .../apache/nifi/admin/dao/impl/DAOFactoryImpl.java |  15 +-
 .../admin/dao/impl/StandardIdpCredentialDAO.java   | 189 +++++
 .../admin/dao/impl/StandardIdpUserGroupDAO.java    | 244 +++++++
 .../nifi/admin/service/IdpCredentialService.java   |  57 ++
 .../nifi/admin/service/IdpUserGroupService.java    |  71 ++
 .../action/CreateIdpCredentialAction.java}         |  29 +-
 .../action/CreateIdpUserGroup.java}                |  28 +-
 .../action/CreateIdpUserGroups.java}               |  29 +-
 .../action/DeleteIdpCredentialByIdAction.java}     |  28 +-
 .../DeleteIdpCredentialByIdentityAction.java}      |  28 +-
 .../action/DeleteIdpUserGroupsByIdentity.java}     |  28 +-
 .../action/GetIdpCredentialByIdentity.java}        |  29 +-
 .../action/GetIdpUserGroupsByIdentity.java}        |  29 +-
 .../service/impl/StandardIdpCredentialService.java | 213 ++++++
 .../service/impl/StandardIdpUserGroupService.java  | 247 +++++++
 .../java/org/apache/nifi/idp/IdpCredential.java    |  84 +++
 .../dao/DAOFactory.java => idp/IdpType.java}       |   9 +-
 .../java/org/apache/nifi/idp/IdpUserGroup.java     |  85 +++
 .../main/resources/nifi-administration-context.xml |  21 +
 .../nifi/authorization/AuthorizerFactory.java      |   5 +
 .../apache/nifi/authorization/FileAuthorizer.java  |   5 +
 .../nifi/authorization/FileUserGroupProvider.java  |  78 ++-
 .../apache/nifi/authorization/UserGroupHolder.java |  23 +
 .../authorization/FileUserGroupProviderTest.java   |  23 +
 .../CompositeConfigurableUserGroupProvider.java    |  11 +
 .../authorization/CompositeUserGroupProvider.java  |  15 +
 .../authorization/StandardManagedAuthorizer.java   |  51 +-
 .../nifi/authorization/user/NiFiUserUtils.java     |   6 +-
 .../nifi/authorization/user/StandardNiFiUser.java  |  35 +
 .../authorization/SimpleUserGroupProvider.java     |   5 +
 .../StandardManagedAuthorizerTest.java             |  57 ++
 .../replication/ThreadPoolRequestReplicator.java   |   5 +
 .../TestThreadPoolRequestReplicator.java           |  99 ++-
 .../authorization/MockPolicyBasedAuthorizer.java   |   5 +
 .../nifi-framework/nifi-resources/pom.xml          |  17 +
 .../src/main/resources/conf/logback.xml            |  26 +-
 .../src/main/resources/conf/nifi.properties        |  17 +
 .../nifi/authorization/ShellUserGroupProvider.java |  35 +-
 .../org/apache/nifi/web/server/JettyServer.java    |  65 +-
 .../nifi/web/NiFiWebApiSecurityConfiguration.java  |  25 +-
 .../apache/nifi/web/StandardNiFiServiceFacade.java |   2 +-
 .../org/apache/nifi/web/api/AccessResource.java    | 759 +++++++++++++++++++--
 .../dao/impl/StandardPolicyBasedAuthorizerDAO.java |   5 +
 .../src/main/resources/nifi-web-api-context.xml    |   5 +
 .../nifi-web/nifi-web-security/pom.xml             |  13 +
 .../nifi/web/security/ProxiedEntitiesUtils.java    |  41 ++
 .../security/jwt/JwtAuthenticationProvider.java    |  26 +-
 .../apache/nifi/web/security/jwt/JwtService.java   |  18 +-
 .../web/security/knox/KnoxServiceFactoryBean.java  |   4 +-
 .../nifi/web/security/logout/LogoutRequest.java    |  61 ++
 .../web/security/logout/LogoutRequestManager.java  |  85 +++
 .../apache/nifi/web/security/oidc/OidcService.java |  44 +-
 .../security/otp/OtpAuthenticationProvider.java    |  26 +-
 .../web/security/saml/NiFiSAMLContextProvider.java |  57 ++
 .../nifi/web/security/saml/SAMLConfiguration.java  |  73 ++
 .../security/saml/SAMLConfigurationFactory.java}   |  19 +-
 .../web/security/saml/SAMLCredentialStore.java     |  39 +-
 .../nifi/web/security/saml/SAMLEndpoints.java      |  42 ++
 .../apache/nifi/web/security/saml/SAMLService.java | 128 ++++
 .../nifi/web/security/saml/SAMLStateManager.java   |  61 ++
 .../saml/impl/NiFiSAMLContextProviderImpl.java     | 114 ++++
 .../saml/impl/StandardSAMLConfiguration.java       | 328 +++++++++
 .../impl/StandardSAMLConfigurationFactory.java     | 502 ++++++++++++++
 .../saml/impl/StandardSAMLCredentialStore.java     | 124 ++++
 .../security/saml/impl/StandardSAMLService.java    | 528 ++++++++++++++
 .../saml/impl/StandardSAMLStateManager.java        | 143 ++++
 .../saml/impl/tls/CompositeKeyManager.java         | 107 +++
 .../impl/tls/CustomTLSProtocolSocketFactory.java   |  69 ++
 .../saml/impl/tls/TruststoreStrategy.java}         |  16 +-
 .../apache/nifi/web/security/util/CacheKey.java    |   3 -
 .../web/security/util/IdentityProviderUtils.java   |  52 ++
 .../security/x509/X509AuthenticationFilter.java    |   8 +-
 .../security/x509/X509AuthenticationProvider.java  |  50 +-
 .../x509/X509AuthenticationRequestToken.java       |  10 +-
 .../main/resources/nifi-web-security-context.xml   |  21 +
 .../web/security/TestProxiedEntitiesUtils.java     |  85 +++
 .../jwt/JwtAuthenticationProviderTest.java         | 153 ++++-
 .../nifi/web/security/jwt/JwtServiceTest.java      |  90 ++-
 .../security/logout/TestLogoutRequestManager.java  |  66 ++
 .../otp/OtpAuthenticationProviderTest.java         | 158 ++++-
 .../saml/impl/TestStandardSAMLService.java         | 123 ++++
 .../saml/impl/TestStandardSAMLStateManager.java    |  90 +++
 .../x509/X509AuthenticationProviderTest.java       |  10 +-
 .../src/test/resources/saml/keystore.jks           | Bin 0 -> 3095 bytes
 .../src/test/resources/saml/sso-circle-meta.xml    |  88 +++
 .../src/test/resources/saml/truststore.jks         | Bin 0 -> 911 bytes
 .../org/apache/nifi/web/filter/LoginFilter.java    |   4 +
 .../org/apache/nifi/web/filter/LogoutFilter.java   |  22 +-
 .../src/main/webapp/js/nf/canvas/nf-canvas.js      |  13 +-
 .../nifi-web-ui/src/main/webapp/js/nf/nf-common.js |   9 +-
 nifi-nar-bundles/nifi-framework-bundle/pom.xml     |   5 +
 .../nifi/ldap/tenants/LdapUserGroupProvider.java   |  42 +-
 .../org/apache/nifi/ldap/tenants/TenantHolder.java |  23 +
 .../ldap/tenants/LdapUserGroupProviderTest.java    |  16 +
 .../ITestPersistentProvenanceRepository.java       |  10 +
 .../index/lucene/TestLuceneEventIndex.java         |  10 +
 .../TestVolatileProvenanceRepository.java          |  29 +-
 pom.xml                                            |   5 +
 109 files changed, 6683 insertions(+), 539 deletions(-)

diff --git a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
index 23b183d..6164a2e 100644
--- a/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
+++ b/nifi-commons/nifi-properties/src/main/java/org/apache/nifi/util/NiFiProperties.java
@@ -16,6 +16,9 @@
  */
 package org.apache.nifi.util;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.BufferedInputStream;
 import java.io.File;
 import java.io.FileInputStream;
@@ -34,8 +37,6 @@ import java.util.Properties;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * The NiFiProperties class holds all properties which are needed for various
@@ -175,6 +176,23 @@ public abstract class NiFiProperties {
     public static final String SECURITY_USER_KNOX_COOKIE_NAME = "nifi.security.user.knox.cookieName";
     public static final String SECURITY_USER_KNOX_AUDIENCES = "nifi.security.user.knox.audiences";
 
+    // saml
+    public static final String SECURITY_USER_SAML_IDP_METADATA_URL = "nifi.security.user.saml.idp.metadata.url";
+    public static final String SECURITY_USER_SAML_SP_ENTITY_ID = "nifi.security.user.saml.sp.entity.id";
+    public static final String SECURITY_USER_SAML_IDENTITY_ATTRIBUTE_NAME = "nifi.security.user.saml.identity.attribute.name";
+    public static final String SECURITY_USER_SAML_GROUP_ATTRIBUTE_NAME = "nifi.security.user.saml.group.attribute.name";
+    public static final String SECURITY_USER_SAML_METADATA_SIGNING_ENABLED = "nifi.security.user.saml.metadata.signing.enabled";
+    public static final String SECURITY_USER_SAML_REQUEST_SIGNING_ENABLED = "nifi.security.user.saml.request.signing.enabled";
+    public static final String SECURITY_USER_SAML_WANT_ASSERTIONS_SIGNED = "nifi.security.user.saml.want.assertions.signed";
+    public static final String SECURITY_USER_SAML_SIGNATURE_ALGORITHM = "nifi.security.user.saml.signature.algorithm";
+    public static final String SECURITY_USER_SAML_SIGNATURE_DIGEST_ALGORITHM = "nifi.security.user.saml.signature.digest.algorithm";
+    public static final String SECURITY_USER_SAML_MESSAGE_LOGGING_ENABLED = "nifi.security.user.saml.message.logging.enabled";
+    public static final String SECURITY_USER_SAML_AUTHENTICATION_EXPIRATION = "nifi.security.user.saml.authentication.expiration";
+    public static final String SECURITY_USER_SAML_SINGLE_LOGOUT_ENABLED = "nifi.security.user.saml.single.logout.enabled";
+    public static final String SECURITY_USER_SAML_HTTP_CLIENT_TRUSTSTORE_STRATEGY = "nifi.security.user.saml.http.client.truststore.strategy";
+    public static final String SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT = "nifi.security.user.saml.http.client.connect.timeout";
+    public static final String SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT = "nifi.security.user.saml.http.client.read.timeout";
+
     // web properties
     public static final String WEB_HTTP_PORT = "nifi.web.http.port";
     public static final String WEB_HTTP_PORT_FORWARDING = "nifi.web.http.port.forwarding";
@@ -301,6 +319,17 @@ public abstract class NiFiProperties {
     public static final String DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_STORAGE = "500 MB";
     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 static final String DEFAULT_SECURITY_USER_SAML_METADATA_SIGNING_ENABLED = "false";
+    public static final String DEFAULT_SECURITY_USER_SAML_REQUEST_SIGNING_ENABLED = "false";
+    public static final String DEFAULT_SECURITY_USER_SAML_WANT_ASSERTIONS_SIGNED = "true";
+    public static final String DEFAULT_SECURITY_USER_SAML_SIGNATURE_ALGORITHM = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
+    public static final String DEFAULT_SECURITY_USER_SAML_DIGEST_ALGORITHM = "http://www.w3.org/2001/04/xmlenc#sha256";
+    public static final String DEFAULT_SECURITY_USER_SAML_MESSAGE_LOGGING_ENABLED = "false";
+    public static final String DEFAULT_SECURITY_USER_SAML_AUTHENTICATION_EXPIRATION = "12 hours";
+    public static final String DEFAULT_SECURITY_USER_SAML_SINGLE_LOGOUT_ENABLED = "false";
+    public static final String DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_TRUSTSTORE_STRATEGY = "JDK";
+    public static final String DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT = "30 secs";
+    public static final String DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT = "30 secs";
     public static final String DEFAULT_WEB_SHOULD_SEND_SERVER_VERSION = "true";
 
     // cluster common defaults
@@ -1038,6 +1067,153 @@ public abstract class NiFiProperties {
     }
 
     /**
+     * Returns whether SAML is enabled.
+     *
+     * @return whether saml is enabled
+     */
+    public boolean isSamlEnabled() {
+        return !StringUtils.isBlank(getSamlIdentityProviderMetadataUrl());
+    }
+
+    /**
+     * The URL to obtain the identity provider metadata.
+     * Must be a value starting with 'file://' or 'http://'.
+     *
+     * @return the url to obtain the identity provider metadata
+     */
+    public String getSamlIdentityProviderMetadataUrl() {
+        return getProperty(SECURITY_USER_SAML_IDP_METADATA_URL);
+    }
+
+    /**
+     * The entity id for the service provider.
+     *
+     * @return the service provider entity id
+     */
+    public String getSamlServiceProviderEntityId() {
+        return getProperty(SECURITY_USER_SAML_SP_ENTITY_ID);
+    }
+
+    /**
+     * The name of an attribute in the SAML assertions that contains the user identity.
+     *
+     * If not specified, or missing, the NameID of the Subject will be used.
+     *
+     * @return the attribute name containing the user identity
+     */
+    public String getSamlIdentityAttributeName() {
+        return getProperty(SECURITY_USER_SAML_IDENTITY_ATTRIBUTE_NAME);
+    }
+
+    /**
+     * The name of the attribute in the SAML assertions that contains the groups the user belongs to.
+     *
+     * @return the attribute name containing user groups
+     */
+    public String getSamlGroupAttributeName() {
+        return getProperty(SECURITY_USER_SAML_GROUP_ATTRIBUTE_NAME);
+    }
+
+    /**
+     * The signing algorithm to use for signing SAML requests.
+     *
+     * @return the signing algorithm to use
+     */
+    public String getSamlSignatureAlgorithm() {
+        return getProperty(SECURITY_USER_SAML_SIGNATURE_ALGORITHM, DEFAULT_SECURITY_USER_SAML_SIGNATURE_ALGORITHM);
+    }
+
+    /**
+     * The digest algorithm to use for signing SAML requests.
+     *
+     * @return the digest algorithm
+     */
+    public String getSamlSignatureDigestAlgorithm() {
+        return getProperty(SECURITY_USER_SAML_SIGNATURE_DIGEST_ALGORITHM, DEFAULT_SECURITY_USER_SAML_DIGEST_ALGORITHM);
+    }
+
+    /**
+     * Whether or not to sign the service provider metadata.
+     *
+     * @return whether or not to sign the service provider metadata
+     */
+    public boolean isSamlMetadataSigningEnabled() {
+        return Boolean.parseBoolean(getProperty(SECURITY_USER_SAML_METADATA_SIGNING_ENABLED, DEFAULT_SECURITY_USER_SAML_METADATA_SIGNING_ENABLED));
+    }
+
+    /**
+     * Whether or not to sign requests sent to the identity provider.
+     *
+     * @return whether or not to sign requests sent to the identity provider
+     */
+    public boolean isSamlRequestSigningEnabled() {
+        return Boolean.parseBoolean(getProperty(SECURITY_USER_SAML_REQUEST_SIGNING_ENABLED, DEFAULT_SECURITY_USER_SAML_REQUEST_SIGNING_ENABLED));
+    }
+
+    /**
+     * Whether or not the identity provider should sign assertions when sending response back.
+     *
+     * @return whether or not the identity provider should sign assertions when sending response back
+     */
+    public boolean isSamlWantAssertionsSigned() {
+        return Boolean.parseBoolean(getProperty(SECURITY_USER_SAML_WANT_ASSERTIONS_SIGNED, DEFAULT_SECURITY_USER_SAML_WANT_ASSERTIONS_SIGNED));
+    }
+
+    /**
+     * Whether or not to log messages for debug purposes.
+     *
+     * @return whether or not to log messages
+     */
+    public boolean isSamlMessageLoggingEnabled() {
+        return Boolean.parseBoolean(getProperty(SECURITY_USER_SAML_MESSAGE_LOGGING_ENABLED, DEFAULT_SECURITY_USER_SAML_MESSAGE_LOGGING_ENABLED));
+    }
+
+    /**
+     * The expiration value for a JWT created from a SAML authentication.
+     *
+     * @return the expiration value for a SAML authentication
+     */
+    public String getSamlAuthenticationExpiration() {
+        return getProperty(SECURITY_USER_SAML_AUTHENTICATION_EXPIRATION, DEFAULT_SECURITY_USER_SAML_AUTHENTICATION_EXPIRATION);
+    }
+
+    /**
+     * Whether or not logging out of NiFi should logout of the SAML IDP using the SAML SingleLogoutService.
+     *
+     * @return whether or not SAML single logout is enabled
+     */
+    public boolean isSamlSingleLogoutEnabled() {
+        return Boolean.parseBoolean(getProperty(SECURITY_USER_SAML_SINGLE_LOGOUT_ENABLED, DEFAULT_SECURITY_USER_SAML_SINGLE_LOGOUT_ENABLED));
+    }
+
+    /**
+     * The truststore to use when interacting with a SAML IDP over https. Valid values are "JDK" and "NIFI".
+     *
+     * @return the type of truststore to use
+     */
+    public String getSamlHttpClientTruststoreStrategy() {
+        return getProperty(SECURITY_USER_SAML_HTTP_CLIENT_TRUSTSTORE_STRATEGY, DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_TRUSTSTORE_STRATEGY);
+    }
+
+    /**
+     * The connect timeout for the http client created for SAML operations.
+     *
+     * @return the connect timeout
+     */
+    public String getSamlHttpClientConnectTimeout() {
+        return getProperty(SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT, DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT);
+    }
+
+    /**
+     * The read timeout for the http client created for SAML operations.
+     *
+     * @return the read timeout
+     */
+    public String getSamlHttpClientReadTimeout() {
+        return getProperty(SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT, DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT);
+    }
+
+    /**
      * Returns true if client certificates are required for REST API. Determined
      * if the following conditions are all true:
      * <p>
@@ -1051,7 +1227,12 @@ public abstract class NiFiProperties {
      * @return true if client certificates are required for access to the REST API
      */
     public boolean isClientAuthRequiredForRestApi() {
-        return !isLoginIdentityProviderEnabled() && !isKerberosSpnegoSupportEnabled() && !isOidcEnabled() && !isKnoxSsoEnabled() && !isAnonymousAuthenticationAllowed();
+        return !isLoginIdentityProviderEnabled()
+                && !isKerberosSpnegoSupportEnabled()
+                && !isOidcEnabled()
+                && !isKnoxSsoEnabled()
+                && !isSamlEnabled()
+                && !isAnonymousAuthenticationAllowed();
     }
 
     public InetSocketAddress getNodeApiAddress() {
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index e933c5d..08da126 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -28,7 +28,7 @@ Apache NiFi can run on something as simple as a laptop, but it can also be clust
 ** Linux
 ** Unix
 ** Windows
-** macOS 
+** macOS
 * Supported Web Browsers:
 ** Microsoft Edge:  Current & (Current - 1)
 ** Mozilla FireFox: Current & (Current - 1)
@@ -343,7 +343,7 @@ Modify _login-identity-providers.xml_ to enable the `kerberos-provider`. Here is
 
 The `kerberos-provider` has the following properties:
 
-[options="header,footer"]
+[options="header"]
 |==================================================================================================================================================
 | Property Name | Description
 |`Default Realm` | Default realm to provide when user enters incomplete user principal (i.e. `NIFI.APACHE.ORG`).
@@ -359,7 +359,7 @@ NOTE: For changes to _nifi.properties_ and _login-identity-providers.xml_ to tak
 
 To enable authentication via OpenId Connect the following properties must be configured in _nifi.properties_.
 
-[options="header,footer"]
+[options="header"]
 |==================================================================================================================================================
 | Property Name | Description
 |`nifi.security.user.oidc.discovery.url` | The discovery URL for the desired OpenId Connect Provider (link:http://openid.net/specs/openid-connect-discovery-1_0.html[http://openid.net/specs/openid-connect-discovery-1_0.html^]).
@@ -375,12 +375,37 @@ JSON Web Key (JWK) provided through the jwks_uri in the metadata found at the di
 |`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.
 |==================================================================================================================================================
 
+[[saml]]
+=== SAML
+
+To enable authentication via SAML the following properties must be configured in _nifi.properties_.
+
+[options="header"]
+|==================================================================================================================================================
+| Property Name | Description
+|`nifi.security.user.saml.idp.metadata.url` | The URL for obtaining the identity provider's metadata. The metadata can be retrieved from the identity provider via `http://` or `https://`, or a local file can be referenced using `file://` .
+|`nifi.security.user.saml.sp.entity.id`| The entity id of the service provider (i.e. NiFi). This value will be used as the `Issuer` for SAML authentication requests and should be a valid URI. In some cases the service provider entity id must be registered ahead of time with the identity provider.
+|`nifi.security.user.saml.identity.attribute.name`| The name of a SAML assertion attribute containing the user'sidentity. This property is optional and if not specified, or if the attribute is not found, then the NameID of the Subject will be used.
+|`nifi.security.user.saml.group.attribute.name`| The name of a SAML assertion attribute containing group names the user belongs to. This property is optional, but if populated the groups will be passed along to the authorization process.
+|`nifi.security.user.saml.metadata.signing.enabled`| Enables signing of the generated service provider metadata.
+|`nifi.security.user.saml.request.signing.enabled`| Controls the value of `AuthnRequestsSigned` in the generated service provider metadata from `nifi-api/access/saml/metadata`. This indicates that the service provider (i.e. NiFi) should not sign authentication requests sent to the identity provider, but the requests may still need to be signed if the identity provider indicates `WantAuthnRequestSigned=true`.
+|`nifi.security.user.saml.want.assertions.signed`| Controls the value of `WantAssertionsSigned` in the generated service provider metadata from `nifi-api/access/saml/metadata`. This indictaes that the identity provider should sign assertions, but some identity providers may provide their own configuration for controlling whether assertions are signed.
+|`nifi.security.user.saml.signature.algorithm`| The algorithm to use when signing SAML messages. Reference the link:https://git.shibboleth.net/view/?p=java-xmltooling.git;a=blob;f=src/main/java/org/opensaml/xml/signature/SignatureConstants.java[Open SAML Signature Constants] for a list of valid values. If not specified, a default of SHA-256 will be used.
+|`nifi.security.user.saml.signature.digest.algorithm`| The digest algorithm to use when signing SAML messages. Reference the link:https://git.shibboleth.net/view/?p=java-xmltooling.git;a=blob;f=src/main/java/org/opensaml/xml/signature/SignatureConstants.java[Open SAML Signature Constants] for a list of valid values. If not specified, a default of SHA-256 will be used.
+|`nifi.security.user.saml.message.logging.enabled`| Enables logging of SAML messages for debugging purposes.
+|`nifi.security.user.saml.authentication.expiration`| The expiration of the NiFi JWT that will be produced from a successful SAML authentication response.
+|`nifi.security.user.saml.single.logout.enabled`| Enables SAML SingleLogout which causes a logout from NiFi to logout of the identity provider. By default, a logout of NiFi will only remove the NiFi JWT.
+|`nifi.security.user.saml.http.client.truststore.strategy`| The truststore strategy when the IDP metadata URL begins with https. A value of `JDK` indicates to use the JDK's default truststore. A value of`NIFI`indicates to use the truststore specified by `nifi.security.truststore`.
+|`nifi.security.user.saml.http.client.connect.timeout`| The connection timeout when communicating with the SAML IDP.
+|`nifi.security.user.saml.http.client.read.timeout`| The read timeout when communicating with the SAML IDP.
+|==================================================================================================================================================
+
 [[apache_knox]]
 === Apache Knox
 
 To enable authentication via Apache Knox the following properties must be configured in _nifi.properties_.
 
-[options="header,footer"]
+[options="header"]
 |==================================================================================================================================================
 | Property Name | Description
 |`nifi.security.user.knox.url` | The URL for the Apache Knox login page.
diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/AbstractPolicyBasedAuthorizer.java b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/AbstractPolicyBasedAuthorizer.java
index a2d55e6..0b86543 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/AbstractPolicyBasedAuthorizer.java
+++ b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/AbstractPolicyBasedAuthorizer.java
@@ -16,20 +16,6 @@
  */
 package org.apache.nifi.authorization;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Set;
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
 import org.apache.nifi.authorization.exception.AuthorizationAccessException;
 import org.apache.nifi.authorization.exception.AuthorizerCreationException;
 import org.apache.nifi.authorization.exception.AuthorizerDestructionException;
@@ -43,6 +29,21 @@ import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+
 /**
  * An Authorizer that provides management of users, groups, and policies.
  */
@@ -150,6 +151,15 @@ public abstract class AbstractPolicyBasedAuthorizer implements ManagedAuthorizer
      */
     public abstract Group getGroup(String identifier) throws AuthorizationAccessException;
 
+    /**
+     * Retrieves a group by name.
+     *
+     * @param name the name of the group to retrieve
+     * @return the group with the given name, or null if no matching group was found
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    public abstract Group getGroupByName(String name) throws AuthorizationAccessException;
+
     protected abstract void purgePoliciesUsersAndGroups();
 
     protected abstract void backupPoliciesUsersAndGroups();
@@ -611,6 +621,11 @@ public abstract class AbstractPolicyBasedAuthorizer implements ManagedAuthorizer
                     }
 
                     @Override
+                    public Group getGroupByName(String name) throws AuthorizationAccessException {
+                        return AbstractPolicyBasedAuthorizer.this.getGroupByName(name);
+                    }
+
+                    @Override
                     public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
                         final UsersAndAccessPolicies usersAndAccessPolicies = AbstractPolicyBasedAuthorizer.this.getUsersAndAccessPolicies();
                         final User user = usersAndAccessPolicies.getUser(identity);
diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/UserGroupProvider.java b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/UserGroupProvider.java
index a7b7a0b..63a7a07 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/UserGroupProvider.java
+++ b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/UserGroupProvider.java
@@ -76,6 +76,30 @@ public interface UserGroupProvider {
     Group getGroup(String identifier) throws AuthorizationAccessException;
 
     /**
+     * Retrieves a Group by name.
+     *
+     * @param name the name of the group to retrieve
+     * @return the Group with the given name, or null if no matching group was found
+     * @throws AuthorizationAccessException if there was an unexpected error performing the operation
+     */
+    default Group getGroupByName(String name) throws AuthorizationAccessException {
+        final Set<Group> allGroups = getGroups();
+        if (allGroups == null) {
+            return null;
+        }
+
+        Group matchingGroup = null;
+        for (Group group : allGroups) {
+            if (group.getName().equals(name)) {
+                matchingGroup = group;
+                break;
+            }
+        }
+
+        return matchingGroup;
+    }
+
+    /**
      * Gets a user and their groups. Must be non null. If the user is not known the UserAndGroups.getUser() and
      * UserAndGroups.getGroups() should return null
      *
diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/resource/Authorizable.java b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/resource/Authorizable.java
index d632feb..7a0d812 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/resource/Authorizable.java
+++ b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/resource/Authorizable.java
@@ -97,7 +97,7 @@ public interface Authorizable {
         final Resource requestedResource = getRequestedResource();
         final AuthorizationRequest request = new AuthorizationRequest.Builder()
                 .identity(user.getIdentity())
-                .groups(user.getGroups())
+                .groups(user.getAllGroups())
                 .anonymous(user.isAnonymous())
                 .accessAttempt(false)
                 .action(action)
@@ -209,7 +209,7 @@ public interface Authorizable {
         final Resource requestedResource = getRequestedResource();
         final AuthorizationRequest request = new AuthorizationRequest.Builder()
                 .identity(user.getIdentity())
-                .groups(user.getGroups())
+                .groups(user.getAllGroups())
                 .anonymous(user.isAnonymous())
                 .accessAttempt(true)
                 .action(action)
diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
index 6b8012b..93005bd 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
+++ b/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
@@ -35,6 +35,18 @@ public interface NiFiUser {
     Set<String> getGroups();
 
     /**
+     * @return the groups that this user belongs to if this nifi is configured to authenticate against an identity provider
+     *          capable of returning group membership information in the authentication response
+     */
+    Set<String> getIdentityProviderGroups();
+
+    /**
+     * @return the combined set of getGroups and getIdentityProviderGroups, all authorization checks should
+     *          use this method to authorize against all know groups
+     */
+    Set<String> getAllGroups();
+
+    /**
      * @return the next user in the proxied entities chain, or <code>null</code> if no more users exist in the chain.
      */
     NiFiUser getChain();
diff --git a/nifi-framework-api/src/test/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java b/nifi-framework-api/src/test/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
index 5d80f12..b5abe94 100644
--- a/nifi-framework-api/src/test/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
+++ b/nifi-framework-api/src/test/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
@@ -61,6 +61,11 @@ public class MockPolicyBasedAuthorizer extends AbstractPolicyBasedAuthorizer {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        return groups.stream().filter(g -> g.getName().equals(name)).findFirst().get();
+    }
+
+    @Override
     protected void purgePoliciesUsersAndGroups() {
 
     }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/IdpDataSourceFactoryBean.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/IdpDataSourceFactoryBean.java
new file mode 100644
index 0000000..d0545e4
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/IdpDataSourceFactoryBean.java
@@ -0,0 +1,164 @@
+/*
+ * 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.admin;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.util.NiFiProperties;
+import org.h2.jdbcx.JdbcConnectionPool;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.FactoryBean;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+
+public class IdpDataSourceFactoryBean implements FactoryBean<JdbcConnectionPool> {
+
+    private static final Logger logger = LoggerFactory.getLogger(KeyDataSourceFactoryBean.class);
+    private static final String NF_USERNAME_PASSWORD = "nf";
+    private static final int MAX_CONNECTIONS = 5;
+
+    // database file name
+    private static final String IDP_DATABASE_FILE_NAME = "nifi-identity-providers";
+
+    // ----------
+    // idp tables
+    // ----------
+
+    private static final String IDP_CREDENTIAL_TABLE_NAME = "IDENTITY_PROVIDER_CREDENTIAL";
+
+    private static final String CREATE_IDP_CREDENTIAL_TABLE = "CREATE TABLE " + IDP_CREDENTIAL_TABLE_NAME + " ("
+            + "ID INT NOT NULL PRIMARY KEY AUTO_INCREMENT, "
+            + "IDENTITY VARCHAR2(4096) NOT NULL, "
+            + "IDP_TYPE VARCHAR2(200) NOT NULL, "
+            + "CREDENTIAL BLOB NOT NULL, "
+            + "CREATED TIMESTAMP NOT NULL, "
+            + "CONSTRAINT UK__IDENTITY UNIQUE (IDENTITY)"
+            + ")";
+
+    private static final String IDP_USER_GROUP_TABLE_NAME = "IDENTITY_PROVIDER_USER_GROUP";
+
+    private static final String CREATE_IDP_USER_GROUP_TABLE = "CREATE TABLE " + IDP_USER_GROUP_TABLE_NAME + " ("
+            + "ID INT NOT NULL PRIMARY KEY AUTO_INCREMENT, "
+            + "IDENTITY VARCHAR2(4096) NOT NULL, "
+            + "IDP_TYPE VARCHAR2(200) NOT NULL, "
+            + "GROUP_NAME VARCHAR2(4096) NOT NULL, "
+            + "CREATED TIMESTAMP NOT NULL, "
+            + "CONSTRAINT UK__IDENTITY_GROUP_NAME UNIQUE (IDENTITY, GROUP_NAME)" +
+            ")";
+
+    private JdbcConnectionPool connectionPool;
+
+    private NiFiProperties properties;
+
+    @Override
+    public JdbcConnectionPool getObject() throws Exception {
+        if (connectionPool == null) {
+
+            // locate the repository directory
+            String repositoryDirectoryPath = properties.getProperty(NiFiProperties.REPOSITORY_DATABASE_DIRECTORY);
+
+            // ensure the repository directory is specified
+            if (repositoryDirectoryPath == null) {
+                throw new NullPointerException("Database directory must be specified.");
+            }
+
+            // create a handle to the repository directory
+            File repositoryDirectory = new File(repositoryDirectoryPath);
+
+            // create a handle to the database directory and file
+            File databaseFile = new File(repositoryDirectory, IDP_DATABASE_FILE_NAME);
+            String databaseUrl = getDatabaseUrl(databaseFile);
+
+            // create the pool
+            connectionPool = JdbcConnectionPool.create(databaseUrl, NF_USERNAME_PASSWORD, NF_USERNAME_PASSWORD);
+            connectionPool.setMaxConnections(MAX_CONNECTIONS);
+
+            Connection connection = null;
+            ResultSet rs = null;
+            Statement statement = null;
+            try {
+                // get a connection
+                connection = connectionPool.getConnection();
+                connection.setAutoCommit(false);
+
+                // create a statement for creating/updating the database
+                statement = connection.createStatement();
+
+                // determine if the idp tables need to be created
+                rs = connection.getMetaData().getTables(null, null, IDP_CREDENTIAL_TABLE_NAME, null);
+                if (!rs.next()) {
+                    statement.execute(CREATE_IDP_CREDENTIAL_TABLE);
+                    statement.execute(CREATE_IDP_USER_GROUP_TABLE);
+                }
+
+                // commit any changes
+                connection.commit();
+            } catch (SQLException sqle) {
+                RepositoryUtils.rollback(connection, logger);
+                throw sqle;
+            } finally {
+                RepositoryUtils.closeQuietly(rs);
+                RepositoryUtils.closeQuietly(statement);
+                RepositoryUtils.closeQuietly(connection);
+            }
+        }
+
+        return connectionPool;
+    }
+
+    private String getDatabaseUrl(File databaseFile) {
+        String databaseUrl = "jdbc:h2:" + databaseFile + ";AUTOCOMMIT=OFF;DB_CLOSE_ON_EXIT=FALSE;LOCK_MODE=3";
+        String databaseUrlAppend = properties.getProperty(NiFiProperties.H2_URL_APPEND);
+        if (StringUtils.isNotBlank(databaseUrlAppend)) {
+            databaseUrl += databaseUrlAppend;
+        }
+        return databaseUrl;
+    }
+
+    @Override
+    public Class getObjectType() {
+        return JdbcConnectionPool.class;
+    }
+
+    @Override
+    public boolean isSingleton() {
+        return true;
+    }
+
+    public void setProperties(NiFiProperties properties) {
+        this.properties = properties;
+    }
+
+    public void shutdown() {
+        // shutdown the connection pool
+        if (connectionPool != null) {
+            try {
+                connectionPool.dispose();
+            } catch (Exception e) {
+                logger.warn("Unable to dispose of connection pool: " + e.getMessage());
+                if (logger.isDebugEnabled()) {
+                    logger.warn(StringUtils.EMPTY, e);
+                }
+            }
+        }
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
index 3fcc6d8..3ea634e 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
@@ -24,4 +24,9 @@ public interface DAOFactory {
     ActionDAO getActionDAO();
 
     KeyDAO getKeyDAO();
+
+    IdpCredentialDAO getIdpCredentialDAO();
+
+    IdpUserGroupDAO getIdpUserGroupDAO();
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpCredentialDAO.java
similarity index 63%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpCredentialDAO.java
index 3fcc6d8..f32115e 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpCredentialDAO.java
@@ -16,12 +16,18 @@
  */
 package org.apache.nifi.admin.dao;
 
-/**
- *
- */
-public interface DAOFactory {
+import org.apache.nifi.idp.IdpCredential;
+
+public interface IdpCredentialDAO {
+
+    IdpCredential createCredential(IdpCredential credential) throws DataAccessException;
+
+    IdpCredential findCredentialById(int id) throws DataAccessException;
+
+    IdpCredential findCredentialByIdentity(String identity) throws DataAccessException;
+
+    int deleteCredentialById(int id) throws DataAccessException;
 
-    ActionDAO getActionDAO();
+    int deleteCredentialByIdentity(String identity) throws DataAccessException;
 
-    KeyDAO getKeyDAO();
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpUserGroupDAO.java
similarity index 58%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpUserGroupDAO.java
index 3fcc6d8..34b5b90 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/IdpUserGroupDAO.java
@@ -16,12 +16,22 @@
  */
 package org.apache.nifi.admin.dao;
 
-/**
- *
- */
-public interface DAOFactory {
+import org.apache.nifi.idp.IdpUserGroup;
+
+import java.util.List;
+
+public interface IdpUserGroupDAO {
+
+    IdpUserGroup createUserGroup(IdpUserGroup userGroup) throws DataAccessException;
+
+    List<IdpUserGroup> createUserGroups(List<IdpUserGroup> userGroups) throws DataAccessException;
+
+    IdpUserGroup findUserGroupById(int id) throws DataAccessException;
+
+    List<IdpUserGroup> findUserGroupsByIdentity(String identity) throws DataAccessException;
+
+    int deleteUserGroupById(int id) throws DataAccessException;
 
-    ActionDAO getActionDAO();
+    int deleteUserGroupsByIdentity(String identity) throws DataAccessException;
 
-    KeyDAO getKeyDAO();
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
index 09ad103..ecfe2e7 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
@@ -16,11 +16,14 @@
  */
 package org.apache.nifi.admin.dao.impl;
 
-import java.sql.Connection;
 import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
 import org.apache.nifi.admin.dao.KeyDAO;
 
+import java.sql.Connection;
+
 /**
  *
  */
@@ -42,4 +45,14 @@ public class DAOFactoryImpl implements DAOFactory {
         return new StandardKeyDAO(connection);
     }
 
+
+    @Override
+    public IdpCredentialDAO getIdpCredentialDAO() {
+        return new StandardIdpCredentialDAO(connection);
+    }
+
+    @Override
+    public IdpUserGroupDAO getIdpUserGroupDAO() {
+        return new StandardIdpUserGroupDAO(connection);
+    }
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpCredentialDAO.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpCredentialDAO.java
new file mode 100644
index 0000000..e9b8f51
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpCredentialDAO.java
@@ -0,0 +1,189 @@
+/*
+ * 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.admin.dao.impl;
+
+import org.apache.nifi.admin.RepositoryUtils;
+import org.apache.nifi.admin.dao.DataAccessException;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
+import org.apache.nifi.idp.IdpCredential;
+import org.apache.nifi.idp.IdpType;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Date;
+
+public class StandardIdpCredentialDAO implements IdpCredentialDAO {
+
+    private static final String INSERT_CREDENTIAL = "INSERT INTO IDENTITY_PROVIDER_CREDENTIAL " +
+            "(IDENTITY, IDP_TYPE, CREDENTIAL, CREATED) VALUES (?, ?, ?, ?)";
+
+    private static final String SELECT_CREDENTIAL_BY_ID = "SELECT ID, IDENTITY, IDP_TYPE, CREDENTIAL, CREATED " +
+            "FROM IDENTITY_PROVIDER_CREDENTIAL " +
+            "WHERE ID = ?";
+
+    private static final String SELECT_CREDENTIAL_BY_IDENTITY = "SELECT ID, IDENTITY, IDP_TYPE, CREDENTIAL, CREATED " +
+            "FROM IDENTITY_PROVIDER_CREDENTIAL " +
+            "WHERE IDENTITY = ?";
+
+    private static final String DELETE_CREDENTIAL_BY_ID = "DELETE FROM IDENTITY_PROVIDER_CREDENTIAL " +
+            "WHERE ID = ?";
+
+    private static final String DELETE_CREDENTIAL_BY_IDENTITY = "DELETE FROM IDENTITY_PROVIDER_CREDENTIAL " +
+            "WHERE IDENTITY = ?";
+
+    private final Connection connection;
+
+    public StandardIdpCredentialDAO(final Connection connection) {
+        this.connection = connection;
+    }
+
+    @Override
+    public IdpCredential createCredential(final IdpCredential credential) throws DataAccessException {
+        if (credential == null) {
+            throw new IllegalArgumentException("Credential cannot be null");
+        }
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // populate the parameters
+            statement = connection.prepareStatement(INSERT_CREDENTIAL, Statement.RETURN_GENERATED_KEYS);
+            statement.setString(1, credential.getIdentity());
+            statement.setString(2, credential.getType().name());
+            statement.setBytes(3, credential.getCredential());
+            statement.setTimestamp(4, new java.sql.Timestamp(credential.getCreated().getTime()));
+
+            // execute the insert
+            int updateCount = statement.executeUpdate();
+            rs = statement.getGeneratedKeys();
+
+            // verify the results
+            if (updateCount == 1 && rs.next()) {
+                credential.setId(rs.getInt(1));
+                return credential;
+            } else {
+                throw new DataAccessException("Unable to save IDP credential.");
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    @Override
+    public IdpCredential findCredentialById(final int id) throws DataAccessException {
+        IdpCredential credential = null;
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // set parameters
+            statement = connection.prepareStatement(SELECT_CREDENTIAL_BY_ID);
+            statement.setInt(1, id);
+
+            // execute the query
+            rs = statement.executeQuery();
+
+            // if the credential was found, add it
+            if (rs.next()) {
+                credential = new IdpCredential();
+                populateCredential(rs, credential);
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+
+        return credential;
+    }
+
+    @Override
+    public IdpCredential findCredentialByIdentity(final String identity) throws DataAccessException {
+        IdpCredential credential = null;
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // set parameters
+            statement = connection.prepareStatement(SELECT_CREDENTIAL_BY_IDENTITY);
+            statement.setString(1, identity);
+
+            // execute the query
+            rs = statement.executeQuery();
+
+            // if the credential was found, add it
+            if (rs.next()) {
+                credential = new IdpCredential();
+                populateCredential(rs, credential);
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+
+        return credential;
+    }
+
+    @Override
+    public int deleteCredentialById(int id) throws DataAccessException {
+        PreparedStatement statement = null;
+        try {
+            statement = connection.prepareStatement(DELETE_CREDENTIAL_BY_ID);
+            statement.setInt(1, id);
+            return statement.executeUpdate();
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } catch (DataAccessException dae) {
+            throw dae;
+        } finally {
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    @Override
+    public int deleteCredentialByIdentity(String identity) throws DataAccessException {
+        PreparedStatement statement = null;
+        try {
+            statement = connection.prepareStatement(DELETE_CREDENTIAL_BY_IDENTITY);
+            statement.setString(1, identity);
+            return statement.executeUpdate();
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } catch (DataAccessException dae) {
+            throw dae;
+        } finally {
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    private void populateCredential(final ResultSet rs, final IdpCredential credential) throws SQLException {
+        credential.setId(rs.getInt("ID"));
+        credential.setIdentity(rs.getString("IDENTITY"));
+        credential.setType(IdpType.valueOf(rs.getString("IDP_TYPE")));
+        credential.setCredential(rs.getBytes("CREDENTIAL"));
+        credential.setCreated(new Date(rs.getTimestamp("CREATED").getTime()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpUserGroupDAO.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpUserGroupDAO.java
new file mode 100644
index 0000000..dd171b3
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/StandardIdpUserGroupDAO.java
@@ -0,0 +1,244 @@
+/*
+ * 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.admin.dao.impl;
+
+import org.apache.nifi.admin.RepositoryUtils;
+import org.apache.nifi.admin.dao.DataAccessException;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.idp.IdpUserGroup;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+public class StandardIdpUserGroupDAO implements IdpUserGroupDAO {
+
+    private static final String INSERT_USER_GROUP = "INSERT INTO IDENTITY_PROVIDER_USER_GROUP " +
+            "(IDENTITY, IDP_TYPE, GROUP_NAME, CREATED) VALUES (?, ?, ?, ?)";
+
+    private static final String SELECT_USER_GROUP_BY_ID = "SELECT ID, IDENTITY, IDP_TYPE, GROUP_NAME, CREATED " +
+            "FROM IDENTITY_PROVIDER_USER_GROUP " +
+            "WHERE ID =?";
+
+    private static final String SELECT_USER_GROUP_BY_IDENTITY = "SELECT ID, IDENTITY, IDP_TYPE, GROUP_NAME, CREATED " +
+            "FROM IDENTITY_PROVIDER_USER_GROUP " +
+            "WHERE IDENTITY =?";
+
+    private static final String DELETE_USER_GROUPS_BY_ID = "DELETE FROM IDENTITY_PROVIDER_USER_GROUP " +
+            "WHERE ID = ?";
+
+    private static final String DELETE_USER_GROUPS_BY_IDENTITY = "DELETE FROM IDENTITY_PROVIDER_USER_GROUP " +
+            "WHERE IDENTITY = ?";
+
+    private final Connection connection;
+
+    public StandardIdpUserGroupDAO(final Connection connection) {
+        this.connection = connection;
+    }
+
+    @Override
+    public IdpUserGroup createUserGroup(final IdpUserGroup userGroup) throws DataAccessException {
+        if (userGroup == null) {
+            throw new IllegalArgumentException("UserGroup cannot be null");
+        }
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // populate the parameters
+            statement = connection.prepareStatement(INSERT_USER_GROUP, Statement.RETURN_GENERATED_KEYS);
+            populateStatement(statement, userGroup);
+
+            // execute the insert
+            int updateCount = statement.executeUpdate();
+            rs = statement.getGeneratedKeys();
+
+            // verify the results
+            if (updateCount == 1 && rs.next()) {
+                userGroup.setId(rs.getInt(1));
+                return userGroup;
+            } else {
+                throw new DataAccessException("Unable to save IDP User Group.");
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    @Override
+    public List<IdpUserGroup> createUserGroups(final List<IdpUserGroup> userGroups) throws DataAccessException {
+        if (userGroups == null) {
+            throw new IllegalArgumentException("UserGroups cannot be null");
+        }
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // populate the parameters
+            statement = connection.prepareStatement(INSERT_USER_GROUP, Statement.RETURN_GENERATED_KEYS);
+
+            for (final IdpUserGroup userGroup : userGroups) {
+                populateStatement(statement, userGroup);
+                statement.addBatch();
+            }
+
+            int[] updateCounts = statement.executeBatch();
+            if (updateCounts.length != userGroups.size()) {
+                throw new DataAccessException("Unable to save IDP User Groups");
+            }
+
+            for (int i=0; i < updateCounts.length; i++) {
+                if (updateCounts[i] == 0) {
+                    throw new DataAccessException("Unable to save IDP User Groups");
+                }
+            }
+
+            rs = statement.getGeneratedKeys();
+
+            int count = 0;
+            while (rs.next()) {
+                final int id = rs.getInt(1);
+                final IdpUserGroup userGroup = userGroups.get(count);
+                userGroup.setId(id);
+                count++;
+            }
+
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+
+        return userGroups;
+    }
+
+    private void populateStatement(PreparedStatement statement, IdpUserGroup userGroup) throws SQLException {
+        statement.setString(1, userGroup.getIdentity());
+        statement.setString(2, userGroup.getType().name());
+        statement.setString(3, userGroup.getGroupName());
+        statement.setTimestamp(4, new java.sql.Timestamp(userGroup.getCreated().getTime()));
+    }
+
+    @Override
+    public IdpUserGroup findUserGroupById(final int id) throws DataAccessException {
+        IdpUserGroup userGroup = null;
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // set parameters
+            statement = connection.prepareStatement(SELECT_USER_GROUP_BY_ID);
+            statement.setInt(1, id);
+
+            // execute the query
+            rs = statement.executeQuery();
+
+            // if the group was found, add it
+            if (rs.next()) {
+                userGroup = new IdpUserGroup();
+                populateUserGroup(rs, userGroup);
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+
+        return userGroup;
+    }
+
+    @Override
+    public List<IdpUserGroup> findUserGroupsByIdentity(final String identity) throws DataAccessException {
+        final List<IdpUserGroup> userGroups = new ArrayList<>();
+
+        PreparedStatement statement = null;
+        ResultSet rs = null;
+        try {
+            // set parameters
+            statement = connection.prepareStatement(SELECT_USER_GROUP_BY_IDENTITY);
+            statement.setString(1, identity);
+
+            // execute the query
+            rs = statement.executeQuery();
+
+            // add any found groups to the result list
+            while (rs.next()) {
+                final IdpUserGroup userGroup = new IdpUserGroup();
+                populateUserGroup(rs, userGroup);
+                userGroups.add(userGroup);
+            }
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } finally {
+            RepositoryUtils.closeQuietly(rs);
+            RepositoryUtils.closeQuietly(statement);
+        }
+
+        return userGroups;
+    }
+
+    @Override
+    public int deleteUserGroupById(int id) throws DataAccessException {
+        PreparedStatement statement = null;
+        try {
+            statement = connection.prepareStatement(DELETE_USER_GROUPS_BY_ID);
+            statement.setInt(1, id);
+            return statement.executeUpdate();
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } catch (DataAccessException dae) {
+            throw dae;
+        } finally {
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    @Override
+    public int deleteUserGroupsByIdentity(final String identity) throws DataAccessException {
+        PreparedStatement statement = null;
+        try {
+            statement = connection.prepareStatement(DELETE_USER_GROUPS_BY_IDENTITY);
+            statement.setString(1, identity);
+            return statement.executeUpdate();
+        } catch (SQLException sqle) {
+            throw new DataAccessException(sqle);
+        } catch (DataAccessException dae) {
+            throw dae;
+        } finally {
+            RepositoryUtils.closeQuietly(statement);
+        }
+    }
+
+    private void populateUserGroup(final ResultSet rs, final IdpUserGroup userGroup) throws SQLException {
+        userGroup.setId(rs.getInt("ID"));
+        userGroup.setIdentity(rs.getString("IDENTITY"));
+        userGroup.setType(IdpType.valueOf(rs.getString("IDP_TYPE")));
+        userGroup.setGroupName(rs.getString("GROUP_NAME"));
+        userGroup.setCreated(new Date(rs.getTimestamp("CREATED").getTime()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpCredentialService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpCredentialService.java
new file mode 100644
index 0000000..84b6d99
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpCredentialService.java
@@ -0,0 +1,57 @@
+/*
+ * 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.admin.service;
+
+import org.apache.nifi.idp.IdpCredential;
+
+/**
+ * Manages IDP Credentials.
+ */
+public interface IdpCredentialService {
+
+    /**
+     * Creates the given credential.
+     *
+     * @param credential the credential
+     * @return the credential with the id
+     */
+    IdpCredential createCredential(IdpCredential credential);
+
+    /**
+     * Gets the credential for the given identity.
+     *
+     * @param identity the user identity
+     * @return the credential or null if one does not exist for the given identity
+     */
+    IdpCredential getCredential(String identity);
+
+    /**
+     * Deletes the credential with the given id.
+     *
+     * @param id the credential id
+     */
+    void deleteCredential(int id);
+
+    /**
+     * Replaces the credential for the given user identity.
+     *
+     * @param credential the new credential
+     * @return the credential with the id
+     */
+    IdpCredential replaceCredential(IdpCredential credential);
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpUserGroupService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpUserGroupService.java
new file mode 100644
index 0000000..244de1d
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/IdpUserGroupService.java
@@ -0,0 +1,71 @@
+/*
+ * 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.admin.service;
+
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.idp.IdpUserGroup;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Manages IDP User Groups.
+ */
+public interface IdpUserGroupService {
+
+    /**
+     * Creates the given user group.
+     *
+     * @param userGroup the user group to create
+     * @return the created user group
+     */
+    IdpUserGroup createUserGroup(IdpUserGroup userGroup);
+
+    /**
+     * Creates the given user groups.
+     *
+     * @param userGroups the user group to create
+     * @return the created user group
+     */
+    List<IdpUserGroup> createUserGroups(List<IdpUserGroup> userGroups);
+
+    /**
+     * Gets the user groups for the given identity.
+     *
+     * @param identity the user identity
+     * @return the list of user groups
+     */
+    List<IdpUserGroup> getUserGroups(String identity);
+
+    /**
+     * Deletes the user groups for the given identity.
+     *
+     * @param identity the user identity
+     */
+    void deleteUserGroups(String identity);
+
+    /**
+     * Replaces any existing groups for the given user identity with a new set specified by the set of group names.
+     *
+     * @param userIdentity the user identity
+     * @param idpType the idp type for the groups
+     * @param groupNames the group names, should already have identity mappings applied if necessary
+     * @return the created groups
+     */
+    List<IdpUserGroup> replaceUserGroups(String userIdentity, IdpType idpType, Set<String> groupNames);
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpCredentialAction.java
similarity index 61%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpCredentialAction.java
index 09ad103..95e0e78 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpCredentialAction.java
@@ -14,32 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
+import org.apache.nifi.idp.IdpCredential;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
-
-    private final Connection connection;
+public class CreateIdpCredentialAction implements AdministrationAction<IdpCredential> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    private final IdpCredential credential;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public CreateIdpCredentialAction(final IdpCredential credential) {
+        this.credential = credential;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public IdpCredential execute(DAOFactory daoFactory) {
+        final IdpCredentialDAO dao = daoFactory.getIdpCredentialDAO();
+        return dao.createCredential(credential);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroup.java
similarity index 61%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroup.java
index 09ad103..9c95c4f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroup.java
@@ -14,32 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
+import org.apache.nifi.idp.IdpUserGroup;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
+public class CreateIdpUserGroup implements AdministrationAction<IdpUserGroup> {
 
-    private final Connection connection;
+    final IdpUserGroup userGroup;
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
-
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public CreateIdpUserGroup(final IdpUserGroup userGroup) {
+        this.userGroup = userGroup;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public IdpUserGroup execute(DAOFactory daoFactory) {
+        final IdpUserGroupDAO userGroupDAO = daoFactory.getIdpUserGroupDAO();
+        return userGroupDAO.createUserGroup(userGroup);
     }
 
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroups.java
similarity index 59%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroups.java
index 09ad103..b6e319b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/CreateIdpUserGroups.java
@@ -14,32 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
+import org.apache.nifi.idp.IdpUserGroup;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
+import java.util.List;
 
-    private final Connection connection;
+public class CreateIdpUserGroups implements AdministrationAction<List<IdpUserGroup>> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    private final List<IdpUserGroup> userGroups;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public CreateIdpUserGroups(List<IdpUserGroup> userGroups) {
+        this.userGroups = userGroups;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public List<IdpUserGroup> execute(DAOFactory daoFactory) {
+        final IdpUserGroupDAO userGroupDAO = daoFactory.getIdpUserGroupDAO();
+        return userGroupDAO.createUserGroups(userGroups);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdAction.java
similarity index 62%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdAction.java
index 09ad103..03aebd7 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdAction.java
@@ -14,32 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
-
-    private final Connection connection;
+public class DeleteIdpCredentialByIdAction implements AdministrationAction<Integer> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    private final Integer id;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public DeleteIdpCredentialByIdAction(final Integer id) {
+        this.id = id;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public Integer execute(DAOFactory daoFactory) {
+        final IdpCredentialDAO dao = daoFactory.getIdpCredentialDAO();
+        return dao.deleteCredentialById(id);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdentityAction.java
similarity index 62%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdentityAction.java
index 09ad103..fb0e047 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpCredentialByIdentityAction.java
@@ -14,32 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
-
-    private final Connection connection;
+public class DeleteIdpCredentialByIdentityAction implements AdministrationAction<Integer> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    private final String identity;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public DeleteIdpCredentialByIdentityAction(final String identity) {
+        this.identity = identity;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public Integer execute(DAOFactory daoFactory) {
+        final IdpCredentialDAO dao = daoFactory.getIdpCredentialDAO();
+        return dao.deleteCredentialByIdentity(identity);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpUserGroupsByIdentity.java
similarity index 62%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpUserGroupsByIdentity.java
index 09ad103..2c6d070 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/DeleteIdpUserGroupsByIdentity.java
@@ -14,32 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
-
-    private final Connection connection;
+public class DeleteIdpUserGroupsByIdentity implements AdministrationAction<Integer> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    final String identity;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public DeleteIdpUserGroupsByIdentity(String identity) {
+        this.identity = identity;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public Integer execute(DAOFactory daoFactory) {
+        final IdpUserGroupDAO userGroupDAO = daoFactory.getIdpUserGroupDAO();
+        return userGroupDAO.deleteUserGroupsByIdentity(identity);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpCredentialByIdentity.java
similarity index 61%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpCredentialByIdentity.java
index 09ad103..0186429 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpCredentialByIdentity.java
@@ -14,32 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpCredentialDAO;
+import org.apache.nifi.idp.IdpCredential;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
-
-    private final Connection connection;
+public class GetIdpCredentialByIdentity implements AdministrationAction<IdpCredential> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    private final String identity;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public GetIdpCredentialByIdentity(final String identity) {
+        this.identity = identity;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public IdpCredential execute(DAOFactory daoFactory) {
+        final IdpCredentialDAO dao = daoFactory.getIdpCredentialDAO();
+        return dao.findCredentialByIdentity(identity);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpUserGroupsByIdentity.java
similarity index 60%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpUserGroupsByIdentity.java
index 09ad103..6a51bc3 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/impl/DAOFactoryImpl.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/action/GetIdpUserGroupsByIdentity.java
@@ -14,32 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao.impl;
+package org.apache.nifi.admin.service.action;
 
-import java.sql.Connection;
-import org.apache.nifi.admin.dao.ActionDAO;
 import org.apache.nifi.admin.dao.DAOFactory;
-import org.apache.nifi.admin.dao.KeyDAO;
+import org.apache.nifi.admin.dao.IdpUserGroupDAO;
+import org.apache.nifi.idp.IdpUserGroup;
 
-/**
- *
- */
-public class DAOFactoryImpl implements DAOFactory {
+import java.util.List;
 
-    private final Connection connection;
+public class GetIdpUserGroupsByIdentity implements AdministrationAction<List<IdpUserGroup>> {
 
-    public DAOFactoryImpl(Connection connection) {
-        this.connection = connection;
-    }
+    final String identity;
 
-    @Override
-    public ActionDAO getActionDAO() {
-        return new StandardActionDAO(connection);
+    public GetIdpUserGroupsByIdentity(String identity) {
+        this.identity = identity;
     }
 
     @Override
-    public KeyDAO getKeyDAO() {
-        return new StandardKeyDAO(connection);
+    public List<IdpUserGroup> execute(DAOFactory daoFactory) {
+        final IdpUserGroupDAO userGroupDAO = daoFactory.getIdpUserGroupDAO();
+        return userGroupDAO.findUserGroupsByIdentity(identity);
     }
-
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpCredentialService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpCredentialService.java
new file mode 100644
index 0000000..bccecd1
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpCredentialService.java
@@ -0,0 +1,213 @@
+/*
+ * 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.admin.service.impl;
+
+import org.apache.nifi.admin.dao.DataAccessException;
+import org.apache.nifi.admin.service.AdministrationException;
+import org.apache.nifi.admin.service.IdpCredentialService;
+import org.apache.nifi.admin.service.action.CreateIdpCredentialAction;
+import org.apache.nifi.admin.service.action.DeleteIdpCredentialByIdAction;
+import org.apache.nifi.admin.service.action.DeleteIdpCredentialByIdentityAction;
+import org.apache.nifi.admin.service.action.GetIdpCredentialByIdentity;
+import org.apache.nifi.admin.service.transaction.Transaction;
+import org.apache.nifi.admin.service.transaction.TransactionBuilder;
+import org.apache.nifi.admin.service.transaction.TransactionException;
+import org.apache.nifi.idp.IdpCredential;
+import org.apache.nifi.util.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+/**
+ * Database implementation of IdpCredentialService.
+ */
+public class StandardIdpCredentialService implements IdpCredentialService {
+
+    private static Logger LOGGER = LoggerFactory.getLogger(StandardIdpCredentialService.class);
+
+    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
+    private final Lock readLock = lock.readLock();
+    private final Lock writeLock = lock.writeLock();
+
+    private TransactionBuilder transactionBuilder;
+
+    @Override
+    public IdpCredential createCredential(final IdpCredential credential) {
+        Transaction transaction = null;
+        IdpCredential createdCredential;
+
+        writeLock.lock();
+        try {
+            // ensure the created date is set
+            if (credential.getCreated() == null) {
+                credential.setCreated(new Date());
+            }
+
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // create the credential
+            final CreateIdpCredentialAction action = new CreateIdpCredentialAction(credential);
+            createdCredential = transaction.execute(action);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+
+        return createdCredential;
+    }
+
+    @Override
+    public IdpCredential getCredential(final String identity) {
+        Transaction transaction = null;
+        IdpCredential credential;
+
+        readLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // get the credential
+            final GetIdpCredentialByIdentity action = new GetIdpCredentialByIdentity(identity);
+            credential = transaction.execute(action);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            readLock.unlock();
+        }
+
+        return credential;
+    }
+
+    @Override
+    public void deleteCredential(final int id) {
+        Transaction transaction = null;
+
+        writeLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // delete the credential
+            final DeleteIdpCredentialByIdAction action = new DeleteIdpCredentialByIdAction(id);
+            Integer rowsDeleted = transaction.execute(action);
+            if (rowsDeleted == 0) {
+                LOGGER.warn("No IDP credential was found to delete for id " + id);
+            }
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+    }
+
+    @Override
+    public IdpCredential replaceCredential(final IdpCredential credential) {
+        final String identity = credential.getIdentity();
+        if (StringUtils.isBlank(identity)) {
+            throw new IllegalArgumentException("Identity is required");
+        }
+
+        Transaction transaction = null;
+        IdpCredential createdCredential;
+
+        writeLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // delete the credential
+            final DeleteIdpCredentialByIdentityAction deleteAction = new DeleteIdpCredentialByIdentityAction(identity);
+            Integer rowsDeleted = transaction.execute(deleteAction);
+            if (rowsDeleted == 0) {
+                LOGGER.debug("No IDP credential was found to delete for id " + identity);
+            }
+
+            // ensure the created date is set for the new credential
+            if (credential.getCreated() == null) {
+                credential.setCreated(new Date());
+            }
+
+            // create the new credential
+            final CreateIdpCredentialAction createAction = new CreateIdpCredentialAction(credential);
+            createdCredential = transaction.execute(createAction);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+
+        return createdCredential;
+    }
+
+    private void rollback(final Transaction transaction) {
+        if (transaction != null) {
+            transaction.rollback();
+        }
+    }
+
+    private void closeQuietly(final Transaction transaction) {
+        if (transaction != null) {
+            try {
+                transaction.close();
+            } catch (final IOException ioe) {
+            }
+        }
+    }
+
+    public void setTransactionBuilder(TransactionBuilder transactionBuilder) {
+        this.transactionBuilder = transactionBuilder;
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpUserGroupService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpUserGroupService.java
new file mode 100644
index 0000000..23facfc
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/service/impl/StandardIdpUserGroupService.java
@@ -0,0 +1,247 @@
+/*
+ * 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.admin.service.impl;
+
+import org.apache.nifi.admin.dao.DataAccessException;
+import org.apache.nifi.admin.service.AdministrationException;
+import org.apache.nifi.admin.service.IdpUserGroupService;
+import org.apache.nifi.admin.service.action.CreateIdpUserGroup;
+import org.apache.nifi.admin.service.action.CreateIdpUserGroups;
+import org.apache.nifi.admin.service.action.DeleteIdpUserGroupsByIdentity;
+import org.apache.nifi.admin.service.action.GetIdpUserGroupsByIdentity;
+import org.apache.nifi.admin.service.transaction.Transaction;
+import org.apache.nifi.admin.service.transaction.TransactionBuilder;
+import org.apache.nifi.admin.service.transaction.TransactionException;
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.idp.IdpUserGroup;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+public class StandardIdpUserGroupService implements IdpUserGroupService {
+
+    private static Logger LOGGER = LoggerFactory.getLogger(StandardIdpUserGroupService.class);
+
+    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
+    private final Lock readLock = lock.readLock();
+    private final Lock writeLock = lock.writeLock();
+
+    private TransactionBuilder transactionBuilder;
+
+    @Override
+    public IdpUserGroup createUserGroup(final IdpUserGroup userGroup) {
+        Transaction transaction = null;
+        IdpUserGroup createdUserGroup;
+
+        writeLock.lock();
+        try {
+            // ensure the created date is set
+            if (userGroup.getCreated() == null) {
+                userGroup.setCreated(new Date());
+            }
+
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // create the user group
+            final CreateIdpUserGroup action = new CreateIdpUserGroup(userGroup);
+            createdUserGroup = transaction.execute(action);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+
+        return createdUserGroup;
+    }
+
+    @Override
+    public List<IdpUserGroup> createUserGroups(final List<IdpUserGroup> userGroups) {
+        Transaction transaction = null;
+        List<IdpUserGroup> createdUserGroups;
+
+        writeLock.lock();
+        try {
+            // ensure the created date is set
+            for (final IdpUserGroup userGroup : userGroups) {
+                if (userGroup.getCreated() == null) {
+                    userGroup.setCreated(new Date());
+                }
+            }
+
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // create the user group
+            final CreateIdpUserGroups action = new CreateIdpUserGroups(userGroups);
+            createdUserGroups = transaction.execute(action);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+
+        return createdUserGroups;
+    }
+
+    @Override
+    public List<IdpUserGroup> getUserGroups(final String identity) {
+        Transaction transaction = null;
+        List<IdpUserGroup> userGroups;
+
+        readLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // get the user groups
+            final GetIdpUserGroupsByIdentity action = new GetIdpUserGroupsByIdentity(identity);
+            userGroups = transaction.execute(action);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            readLock.unlock();
+        }
+
+        return userGroups;
+    }
+
+    @Override
+    public void deleteUserGroups(final String identity) {
+        Transaction transaction = null;
+
+        writeLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // delete the credential
+            final DeleteIdpUserGroupsByIdentity action = new DeleteIdpUserGroupsByIdentity(identity);
+            Integer rowsDeleted = transaction.execute(action);
+            LOGGER.debug("Deleted {} user groups for identity {}", rowsDeleted, identity);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+    }
+
+    @Override
+    public List<IdpUserGroup> replaceUserGroups(final String userIdentity, final IdpType idpType, final Set<String> groupNames) {
+        Transaction transaction = null;
+        List<IdpUserGroup> createdUserGroups;
+
+        writeLock.lock();
+        try {
+            // start the transaction
+            transaction = transactionBuilder.start();
+
+            // delete the existing groups
+            final DeleteIdpUserGroupsByIdentity deleteAction = new DeleteIdpUserGroupsByIdentity(userIdentity);
+            Integer rowsDeleted = transaction.execute(deleteAction);
+            LOGGER.debug("Deleted {} user groups for identity {}", rowsDeleted, userIdentity);
+
+            // create the user groups
+            final List<IdpUserGroup> idpUserGroups = new ArrayList<>();
+            for (final String groupName : groupNames) {
+                final IdpUserGroup idpUserGroup = new IdpUserGroup();
+                idpUserGroup.setIdentity(userIdentity);
+                idpUserGroup.setType(idpType);
+                idpUserGroup.setGroupName(groupName);
+                idpUserGroup.setCreated(new Date());
+                idpUserGroups.add(idpUserGroup);
+                LOGGER.debug("{} belongs to {}", userIdentity, groupName);
+            }
+
+            final CreateIdpUserGroups createAction = new CreateIdpUserGroups(idpUserGroups);
+            createdUserGroups = transaction.execute(createAction);
+
+            // commit the transaction
+            transaction.commit();
+        } catch (TransactionException | DataAccessException te) {
+            rollback(transaction);
+            throw new AdministrationException(te);
+        } catch (Throwable t) {
+            rollback(transaction);
+            throw t;
+        } finally {
+            closeQuietly(transaction);
+            writeLock.unlock();
+        }
+
+        return createdUserGroups;
+    }
+
+    private void rollback(final Transaction transaction) {
+        if (transaction != null) {
+            transaction.rollback();
+        }
+    }
+
+    private void closeQuietly(final Transaction transaction) {
+        if (transaction != null) {
+            try {
+                transaction.close();
+            } catch (final IOException ioe) {
+            }
+        }
+    }
+
+    public void setTransactionBuilder(TransactionBuilder transactionBuilder) {
+        this.transactionBuilder = transactionBuilder;
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpCredential.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpCredential.java
new file mode 100644
index 0000000..d62cc05
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpCredential.java
@@ -0,0 +1,84 @@
+/*
+ * 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.idp;
+
+import java.util.Date;
+
+public class IdpCredential {
+
+    private int id;
+    private String identity;
+    private IdpType type;
+    private byte[] credential;
+    private Date created;
+
+    public IdpCredential() {
+
+    }
+
+    public IdpCredential(int id, String identity, IdpType type, byte[] credential) {
+       this(id, identity, type, credential, new Date());
+    }
+
+    public IdpCredential(int id, String identity, IdpType type, byte[] credential, Date created) {
+        this.id = id;
+        this.identity = identity;
+        this.type = type;
+        this.credential = credential;
+        this.created = created;
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public void setId(int id) {
+        this.id = id;
+    }
+
+    public String getIdentity() {
+        return identity;
+    }
+
+    public void setIdentity(String identity) {
+        this.identity = identity;
+    }
+
+    public IdpType getType() {
+        return type;
+    }
+
+    public void setType(IdpType type) {
+        this.type = type;
+    }
+
+    public byte[] getCredential() {
+        return credential;
+    }
+
+    public void setCredential(byte[] credential) {
+        this.credential = credential;
+    }
+
+    public Date getCreated() {
+        return created;
+    }
+
+    public void setCreated(Date created) {
+        this.created = created;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpType.java
similarity index 86%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpType.java
index 3fcc6d8..2ce9b34 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpType.java
@@ -14,14 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao;
+package org.apache.nifi.idp;
 
 /**
- *
+ * Types of identity providers.
  */
-public interface DAOFactory {
+public enum IdpType {
 
-    ActionDAO getActionDAO();
+    SAML;
 
-    KeyDAO getKeyDAO();
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpUserGroup.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpUserGroup.java
new file mode 100644
index 0000000..2daf0b6
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/idp/IdpUserGroup.java
@@ -0,0 +1,85 @@
+/*
+ * 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.idp;
+
+import java.util.Date;
+
+public class IdpUserGroup {
+
+    private int id;
+    private String identity;
+    private IdpType type;
+    private String groupName;
+    private Date created;
+
+    public IdpUserGroup() {
+
+    }
+
+    public IdpUserGroup(int id, String identity, IdpType type, String groupName) {
+        this(id, identity, type, groupName, new Date());
+    }
+
+    public IdpUserGroup(int id, String identity, IdpType type, String groupName, Date created) {
+        this.id = id;
+        this.identity = identity;
+        this.type = type;
+        this.groupName = groupName;
+        this.created = created;
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public void setId(int id) {
+        this.id = id;
+    }
+
+    public String getIdentity() {
+        return identity;
+    }
+
+    public void setIdentity(String identity) {
+        this.identity = identity;
+    }
+
+    public IdpType getType() {
+        return type;
+    }
+
+    public void setType(IdpType type) {
+        this.type = type;
+    }
+
+    public String getGroupName() {
+        return groupName;
+    }
+
+    public void setGroupName(String groupName) {
+        this.groupName = groupName;
+    }
+
+    public Date getCreated() {
+        return created;
+    }
+
+    public void setCreated(Date created) {
+        this.created = created;
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/resources/nifi-administration-context.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/resources/nifi-administration-context.xml
index e717686..baf089f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/resources/nifi-administration-context.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/resources/nifi-administration-context.xml
@@ -28,6 +28,11 @@
         <property name="properties" ref="nifiProperties"/>
     </bean>
 
+    <!-- initialize the idp data source -->
+    <bean id="idpDataSource" class="org.apache.nifi.admin.IdpDataSourceFactoryBean" destroy-method="shutdown">
+        <property name="properties" ref="nifiProperties"/>
+    </bean>
+
     <!-- initialize the user key transaction builder -->
     <bean id="keyTransactionBuilder" class="org.apache.nifi.admin.service.transaction.impl.StandardTransactionBuilder">
         <property name="dataSource" ref="keyDataSource"/>
@@ -38,6 +43,11 @@
         <property name="dataSource" ref="auditDataSource"/>
     </bean>
 
+    <!-- initialize the idp transaction builder -->
+    <bean id="idpTransactionBuilder" class="org.apache.nifi.admin.service.transaction.impl.StandardTransactionBuilder">
+        <property name="dataSource" ref="idpDataSource"/>
+    </bean>
+
     <!-- administration service -->
     <bean id="keyService" class="org.apache.nifi.admin.service.impl.StandardKeyService">
         <property name="transactionBuilder" ref="keyTransactionBuilder"/>
@@ -48,4 +58,15 @@
     <bean id="auditService" class="org.apache.nifi.admin.service.impl.StandardAuditService">
         <property name="transactionBuilder" ref="auditTransactionBuilder"/>
     </bean>
+
+    <!-- idp credential service -->
+    <bean id="idpCredentialService" class="org.apache.nifi.admin.service.impl.StandardIdpCredentialService">
+        <property name="transactionBuilder" ref="idpTransactionBuilder"/>
+    </bean>
+
+    <!-- idp user group service -->
+    <bean id="idpUserGroupService" class="org.apache.nifi.admin.service.impl.StandardIdpUserGroupService">
+        <property name="transactionBuilder" ref="idpTransactionBuilder"/>
+    </bean>
+
 </beans>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-authorizer/src/main/java/org/apache/nifi/authorization/AuthorizerFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-authorizer/src/main/java/org/apache/nifi/authorization/AuthorizerFactory.java
index 933b235..3563975 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-authorizer/src/main/java/org/apache/nifi/authorization/AuthorizerFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-authorizer/src/main/java/org/apache/nifi/authorization/AuthorizerFactory.java
@@ -330,6 +330,11 @@ public final class AuthorizerFactory {
                                         }
 
                                         @Override
+                                        public Group getGroupByName(String name) throws AuthorizationAccessException {
+                                            return baseConfigurableUserGroupProvider.getGroupByName(name);
+                                        }
+
+                                        @Override
                                         public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
                                             return baseConfigurableUserGroupProvider.getUserAndGroups(identity);
                                         }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileAuthorizer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileAuthorizer.java
index 6ab1643..3700cf0 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileAuthorizer.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileAuthorizer.java
@@ -156,6 +156,11 @@ public class FileAuthorizer extends AbstractPolicyBasedAuthorizer {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        return userGroupProvider.getGroupByName(name);
+    }
+
+    @Override
     public synchronized Group doUpdateGroup(Group group) throws AuthorizationAccessException {
         return userGroupProvider.updateGroup(group);
     }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileUserGroupProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileUserGroupProvider.java
index b184ead..f59f718 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/FileUserGroupProvider.java
@@ -16,40 +16,6 @@
  */
 package org.apache.nifi.authorization;
 
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.nio.charset.StandardCharsets;
-import java.text.DateFormat;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.atomic.AtomicReference;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.xml.XMLConstants;
-import javax.xml.bind.JAXBContext;
-import javax.xml.bind.JAXBElement;
-import javax.xml.bind.JAXBException;
-import javax.xml.bind.Marshaller;
-import javax.xml.bind.Unmarshaller;
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamReader;
-import javax.xml.stream.XMLStreamWriter;
-import javax.xml.transform.stream.StreamSource;
-import javax.xml.validation.Schema;
-import javax.xml.validation.SchemaFactory;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.authorization.annotation.AuthorizerContext;
 import org.apache.nifi.authorization.exception.AuthorizationAccessException;
@@ -73,6 +39,41 @@ import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
+import javax.xml.XMLConstants;
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBElement;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Marshaller;
+import javax.xml.bind.Unmarshaller;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import javax.xml.stream.XMLStreamWriter;
+import javax.xml.transform.stream.StreamSource;
+import javax.xml.validation.Schema;
+import javax.xml.validation.SchemaFactory;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
 public class FileUserGroupProvider implements ConfigurableUserGroupProvider {
 
     private static final Logger logger = LoggerFactory.getLogger(FileUserGroupProvider.class);
@@ -369,6 +370,15 @@ public class FileUserGroupProvider implements ConfigurableUserGroupProvider {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        if (name == null) {
+            return null;
+        }
+
+        return userGroupHolder.get().getGroupsByName().get(name);
+    }
+
+    @Override
     public UserAndGroups getUserAndGroups(final String identity) throws AuthorizationAccessException {
         final UserGroupHolder holder = userGroupHolder.get();
         final User user = holder.getUser(identity);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/UserGroupHolder.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/UserGroupHolder.java
index 44cedd8..03eb744 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/UserGroupHolder.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/main/java/org/apache/nifi/authorization/UserGroupHolder.java
@@ -40,6 +40,7 @@ public class UserGroupHolder {
 
     private final Set<Group> allGroups;
     private final Map<String,Group> groupsById;
+    private final Map<String,Group> groupsByName;
     private final Map<String, Set<Group>> groupsByUserIdentity;
 
     /**
@@ -67,6 +68,9 @@ public class UserGroupHolder {
         // create a convenience map to retrieve a group by id
         final Map<String, Group> groupByIdMap = Collections.unmodifiableMap(createGroupByIdMap(allGroups));
 
+        // create a convenience map to retrieve a group by name
+        final Map<String, Group> groupByNameMap = Collections.unmodifiableMap(createGroupByNameMap(allGroups));
+
         // create a convenience map to retrieve the groups for a user identity
         final Map<String, Set<Group>> groupsByUserIdentityMap = Collections.unmodifiableMap(createGroupsByUserIdentityMap(allGroups, allUsers));
 
@@ -76,6 +80,7 @@ public class UserGroupHolder {
         this.usersById = userByIdMap;
         this.usersByIdentity = userByIdentityMap;
         this.groupsById = groupByIdMap;
+        this.groupsByName = groupByNameMap;
         this.groupsByUserIdentity = groupsByUserIdentityMap;
     }
 
@@ -173,6 +178,20 @@ public class UserGroupHolder {
     }
 
     /**
+     * Creates a Map from group name to group.
+     *
+     * @param groups the set of all groups
+     * @return the Map from name to Group
+     */
+    private Map<String,Group> createGroupByNameMap(final Set<Group> groups) {
+        Map<String,Group> groupsMap = new HashMap<>();
+        for (Group group : groups) {
+            groupsMap.put(group.getName(), group);
+        }
+        return groupsMap;
+    }
+
+    /**
      * Creates a Map from user identity to the set of Groups for that identity.
      *
      * @param groups all groups
@@ -222,6 +241,10 @@ public class UserGroupHolder {
         return groupsById;
     }
 
+    public Map<String, Group> getGroupsByName() {
+        return groupsByName;
+    }
+
     public User getUser(String identity) {
         if (identity == null) {
             throw new IllegalArgumentException("Identity cannot be null");
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/test/java/org/apache/nifi/authorization/FileUserGroupProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/test/java/org/apache/nifi/authorization/FileUserGroupProviderTest.java
index 71d376d..2f51e1f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/test/java/org/apache/nifi/authorization/FileUserGroupProviderTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-file-authorizer/src/test/java/org/apache/nifi/authorization/FileUserGroupProviderTest.java
@@ -615,6 +615,29 @@ public class FileUserGroupProviderTest {
     }
 
     @Test
+    public void testGetGroupByNameWhenFound() throws Exception {
+        writeFile(primaryTenants, TENANTS);
+        userGroupProvider.onConfigured(configurationContext);
+        assertEquals(2, userGroupProvider.getGroups().size());
+
+        final String name = "group-1";
+        final Group group = userGroupProvider.getGroupByName(name);
+        assertNotNull(group);
+        assertEquals(name, group.getName());
+    }
+
+    @Test
+    public void testGetGroupByNameWhenNotFound() throws Exception {
+        writeFile(primaryTenants, TENANTS);
+        userGroupProvider.onConfigured(configurationContext);
+        assertEquals(2, userGroupProvider.getGroups().size());
+
+        final String name = "group-X";
+        final Group group = userGroupProvider.getGroupByName(name);
+        assertNull(group);
+    }
+
+    @Test
     public void testGetGroupByIdentifierWhenFound() throws Exception {
         writeFile(primaryTenants, TENANTS);
         userGroupProvider.onConfigured(configurationContext);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeConfigurableUserGroupProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeConfigurableUserGroupProvider.java
index b9ecf9d..3197002 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeConfigurableUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeConfigurableUserGroupProvider.java
@@ -189,6 +189,17 @@ public class CompositeConfigurableUserGroupProvider extends CompositeUserGroupPr
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        Group group = configurableUserGroupProvider.getGroupByName(name);
+
+        if (group == null) {
+            group = super.getGroupByName(name);
+        }
+
+        return group;
+    }
+
+    @Override
     public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
         final CompositeUserAndGroups combinedResult;
 
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeUserGroupProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeUserGroupProvider.java
index 2bef05e..ca0888b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/CompositeUserGroupProvider.java
@@ -148,6 +148,21 @@ public class CompositeUserGroupProvider implements UserGroupProvider {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        Group group = null;
+
+        for (final UserGroupProvider userGroupProvider : userGroupProviders) {
+            group = userGroupProvider.getGroupByName(name);
+
+            if (group != null) {
+                break;
+            }
+        }
+
+        return group;
+    }
+
+    @Override
     public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
 
         // This method builds a UserAndGroups response by combining the data from all providers using a two-pass approach
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/StandardManagedAuthorizer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/StandardManagedAuthorizer.java
index 7f31136..b6d0929 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/StandardManagedAuthorizer.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/StandardManagedAuthorizer.java
@@ -16,16 +16,6 @@
  */
 package org.apache.nifi.authorization;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.nio.charset.StandardCharsets;
-import java.util.Set;
-import javax.xml.parsers.DocumentBuilder;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.stream.XMLOutputFactory;
-import javax.xml.stream.XMLStreamException;
-import javax.xml.stream.XMLStreamWriter;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.authorization.exception.AuthorizationAccessException;
 import org.apache.nifi.authorization.exception.AuthorizerCreationException;
@@ -39,6 +29,19 @@ import org.w3c.dom.Node;
 import org.w3c.dom.NodeList;
 import org.xml.sax.SAXException;
 
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.stream.XMLOutputFactory;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamWriter;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
 public class StandardManagedAuthorizer implements ManagedAuthorizer {
     private static final XMLOutputFactory XML_OUTPUT_FACTORY = XMLOutputFactory.newInstance();
 
@@ -91,14 +94,40 @@ public class StandardManagedAuthorizer implements ManagedAuthorizer {
             return AuthorizationResult.denied(String.format("Unknown user with identity '%s'.", request.getIdentity()));
         }
 
+        // combine groups from incoming request with groups from UserAndGroups because the request may contain groups from
+        // an external identity provider and the membership may not be maintained with in any of the UserGroupProviders
+
         final Set<Group> userGroups = userAndGroups.getGroups();
-        if (policy.getUsers().contains(user.getIdentifier()) || containsGroup(userGroups, policy)) {
+        final Set<Group> requestGroups = getGroups(request.getGroups());
+
+        final Set<Group> allGroups = new HashSet<>();
+        allGroups.addAll(userGroups == null ? Collections.emptySet() : userGroups);
+        allGroups.addAll(requestGroups == null ? Collections.emptySet() : requestGroups);
+
+        if (policy.getUsers().contains(user.getIdentifier()) || containsGroup(allGroups, policy)) {
             return AuthorizationResult.approved();
         }
 
         return AuthorizationResult.denied(request.getExplanationSupplier().get());
     }
 
+    private Set<Group> getGroups(final Set<String> groupNames) {
+        if (groupNames == null || groupNames.isEmpty()) {
+            return Collections.emptySet();
+        }
+
+        final Set<Group> groups = new HashSet<>();
+
+        for (final String requestGroupName : groupNames) {
+            final Group requestGroup = userGroupProvider.getGroupByName(requestGroupName);
+            if (requestGroup != null) {
+                groups.add(requestGroup);
+            }
+        }
+
+        return groups;
+    }
+
     /**
      * Determines if the policy contains one of the user's groups.
      *
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/NiFiUserUtils.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/NiFiUserUtils.java
index 93e070d..954488e 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/NiFiUserUtils.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/NiFiUserUtils.java
@@ -16,13 +16,14 @@
  */
 package org.apache.nifi.authorization.user;
 
-import java.util.ArrayList;
-import java.util.List;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContext;
 import org.springframework.security.core.context.SecurityContextHolder;
 
+import java.util.ArrayList;
+import java.util.List;
+
 /**
  * Utility methods for retrieving information about the current application user.
  *
@@ -87,4 +88,5 @@ public final class NiFiUserUtils {
 
         return proxyChain;
     }
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/StandardNiFiUser.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/StandardNiFiUser.java
index 8c1619a..bc5653f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/StandardNiFiUser.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/main/java/org/apache/nifi/authorization/user/StandardNiFiUser.java
@@ -19,6 +19,7 @@ package org.apache.nifi.authorization.user;
 import org.apache.commons.lang3.StringUtils;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Objects;
 import java.util.Set;
 
@@ -32,6 +33,8 @@ public class StandardNiFiUser implements NiFiUser {
 
     private final String identity;
     private final Set<String> groups;
+    private final Set<String> identityProviderGroups;
+    private final Set<String> allGroups;
     private final NiFiUser chain;
     private final String clientAddress;
     private final boolean isAnonymous;
@@ -39,9 +42,19 @@ public class StandardNiFiUser implements NiFiUser {
     private StandardNiFiUser(final Builder builder) {
         this.identity = builder.identity;
         this.groups = builder.groups == null ? null : Collections.unmodifiableSet(builder.groups);
+        this.identityProviderGroups = builder.identityProviderGroups == null ? null : Collections.unmodifiableSet(builder.identityProviderGroups);
         this.chain = builder.chain;
         this.clientAddress = builder.clientAddress;
         this.isAnonymous = builder.isAnonymous;
+
+        final Set<String> combineGroups = new HashSet<>();
+        if (this.groups != null) {
+            combineGroups.addAll(this.groups);
+        }
+        if (this.identityProviderGroups != null) {
+            combineGroups.addAll(this.identityProviderGroups);
+        }
+        this.allGroups = Collections.unmodifiableSet(combineGroups);
     }
 
     /**
@@ -66,6 +79,16 @@ public class StandardNiFiUser implements NiFiUser {
     }
 
     @Override
+    public Set<String> getIdentityProviderGroups() {
+        return identityProviderGroups;
+    }
+
+    @Override
+    public Set<String> getAllGroups() {
+        return allGroups;
+    }
+
+    @Override
     public NiFiUser getChain() {
         return chain;
     }
@@ -120,6 +143,7 @@ public class StandardNiFiUser implements NiFiUser {
 
         private String identity;
         private Set<String> groups;
+        private Set<String> identityProviderGroups;
         private NiFiUser chain;
         private String clientAddress;
         private boolean isAnonymous = false;
@@ -147,6 +171,17 @@ public class StandardNiFiUser implements NiFiUser {
         }
 
         /**
+         * Sets the groups that came from an identity provider.
+         *
+         * @param identityProviderGroups the identity provider user groups
+         * @return the builder
+         */
+        public Builder identityProviderGroups(final Set<String> identityProviderGroups) {
+            this.identityProviderGroups = identityProviderGroups;
+            return this;
+        }
+
+        /**
          * Sets the chain.
          *
          * @param chain the proxy chain that leads to this users
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/SimpleUserGroupProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/SimpleUserGroupProvider.java
index 7bafe3f..a69143f 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/SimpleUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/SimpleUserGroupProvider.java
@@ -59,6 +59,11 @@ public class SimpleUserGroupProvider implements UserGroupProvider {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        return groups.stream().filter(groups -> groups.getName().equals(name)).findFirst().orElse(null);
+    }
+
+    @Override
     public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
         final User user = users.stream().filter(u -> u.getIdentity().equals(identity)).findFirst().orElse(null);
         return new UserAndGroups() {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/StandardManagedAuthorizerTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/StandardManagedAuthorizerTest.java
index 36c3724..e3cc9e0 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/StandardManagedAuthorizerTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-authorization/src/test/java/org/apache/nifi/authorization/StandardManagedAuthorizerTest.java
@@ -304,6 +304,63 @@ public class StandardManagedAuthorizerTest {
     }
 
     @Test
+    public void testAuthorizationByRequestGroups() throws Exception {
+        final String userIdentifier = "userIdentifier1";
+        final String userIdentity = "userIdentity1";
+        final String groupIdentifier = "groupIdentifier1";
+
+        final User user = new User.Builder()
+                .identity(userIdentity)
+                .identifier(userIdentifier)
+                .build();
+
+        // don't add the user to the group, group membership will come from the groups on the request
+        final Group group = new Group.Builder()
+                .identifier(groupIdentifier)
+                .name(groupIdentifier)
+                .build();
+
+        final AccessPolicy policy = new AccessPolicy.Builder()
+                .identifier("1")
+                .resource(TEST_RESOURCE.getIdentifier())
+                .addGroup(groupIdentifier)
+                .action(RequestAction.READ)
+                .build();
+
+        final ConfigurableUserGroupProvider userGroupProvider = mock(ConfigurableUserGroupProvider.class);
+        when(userGroupProvider.getUserAndGroups(userIdentity)).thenReturn(new UserAndGroups() {
+            @Override
+            public User getUser() {
+                return user;
+            }
+
+            // no groups for the user in the UGP, groups come from request
+            @Override
+            public Set<Group> getGroups() {
+                return Collections.emptySet();
+            }
+        });
+
+        final ConfigurableAccessPolicyProvider accessPolicyProvider = mock(ConfigurableAccessPolicyProvider.class);
+        when(accessPolicyProvider.getAccessPolicy(TEST_RESOURCE.getIdentifier(), RequestAction.READ)).thenReturn(policy);
+        when(accessPolicyProvider.getUserGroupProvider()).thenReturn(userGroupProvider);
+        when(userGroupProvider.getGroupByName(group.getName())).thenReturn(group);
+
+        // simulate groups being passed in on request from NiFiUser getAllGroups()
+        final AuthorizationRequest request = new AuthorizationRequest.Builder()
+                .identity(userIdentity)
+                .groups(Collections.singleton(group.getName()))
+                .resource(TEST_RESOURCE)
+                .action(RequestAction.READ)
+                .accessAttempt(true)
+                .anonymous(false)
+                .build();
+
+        final StandardManagedAuthorizer managedAuthorizer = getStandardManagedAuthorizer(accessPolicyProvider);
+        assertEquals(AuthorizationResult.approved(), managedAuthorizer.authorize(request));
+    }
+
+    @Test
     public void testResourceNotFound() throws Exception {
         final String userIdentity = "userIdentity1";
 
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
index 610d00b..49383fd 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ThreadPoolRequestReplicator.java
@@ -237,6 +237,11 @@ public class ThreadPoolRequestReplicator implements RequestReplicator {
         final String proxiedEntitiesChain = ProxiedEntitiesUtils.buildProxiedEntitiesChainString(user);
         headers.put(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN, proxiedEntitiesChain);
 
+        // Add the header containing the group information for the end user in the proxied entity chain, these groups would
+        // only be populated if the end user authenticated against an external identity provider like SAML or OIDC
+        final String proxiedEntityGroups = ProxiedEntitiesUtils.buildProxiedEntityGroupsString(user.getIdentityProviderGroups());
+        headers.put(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS, proxiedEntityGroups);
+
         // remove the access token if present, since the user is already authenticated... authorization
         // will happen when the request is replicated using the proxy chain above
         headers.remove(JwtAuthenticationFilter.AUTHORIZATION);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestThreadPoolRequestReplicator.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestThreadPoolRequestReplicator.java
index 6dc5079..f4406f8 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestThreadPoolRequestReplicator.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/replication/TestThreadPoolRequestReplicator.java
@@ -16,31 +16,6 @@
  */
 package org.apache.nifi.cluster.coordination.http.replication;
 
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotNull;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import java.net.SocketTimeoutException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicInteger;
-import javax.ws.rs.HttpMethod;
-import javax.ws.rs.ProcessingException;
-import javax.ws.rs.core.MultivaluedHashMap;
-import javax.ws.rs.core.Response;
-import javax.ws.rs.core.Response.Status;
 import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
 import org.apache.nifi.authorization.user.NiFiUserUtils;
@@ -70,6 +45,34 @@ import org.mockito.stubbing.Answer;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.ProcessingException;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
 public class TestThreadPoolRequestReplicator {
 
     @BeforeClass
@@ -166,6 +169,35 @@ public class TestThreadPoolRequestReplicator {
         }, Response.Status.OK, 0L, null, "<" + userIdentity + "><" + proxyIdentity1 + "><" + proxyIdentity2 +">");
     }
 
+    @Test
+    public void testRequestChainWithIdentityProviderGroups() {
+        final String idpGroup1 = "idp-group-1";
+        final String idpGroup2 = "idp-group-2";
+        final Set<String> idpGroups = new LinkedHashSet<>(Arrays.asList(idpGroup1, idpGroup2));
+        final String expectedProxiedEntityGroups = "<" + idpGroup1 + "><" + idpGroup2 + ">";
+
+        final String proxyIdentity2 = "proxy-2";
+        final String proxyIdentity1 = "proxy-1";
+        final String userIdentity = "user";
+        final String expectedRequestChain = "<" + userIdentity + "><" + proxyIdentity1 + "><" + proxyIdentity2 +">";
+
+        withReplicator(replicator -> {
+            final Set<NodeIdentifier> nodeIds = new HashSet<>();
+            nodeIds.add(new NodeIdentifier("1", "localhost", 8000, "localhost", 8001, "localhost", 8002, 8003, false));
+            final URI uri = new URI("http://localhost:8080/processors/1");
+            final Entity entity = new ProcessorEntity();
+
+            // set the user
+            final NiFiUser proxy2 = new Builder().identity(proxyIdentity2).build();
+            final NiFiUser proxy1 = new Builder().identity(proxyIdentity1).chain(proxy2).build();
+            final NiFiUser user = new Builder().identity(userIdentity).identityProviderGroups(idpGroups).chain(proxy1).build();
+            final Authentication authentication = new NiFiAuthenticationToken(new NiFiUserDetails(user));
+            SecurityContextHolder.getContext().setAuthentication(authentication);
+
+            replicator.replicate(nodeIds, HttpMethod.GET, uri, entity, new HashMap<>(), true, true);
+        }, Response.Status.OK, 0L, null, expectedRequestChain, expectedProxiedEntityGroups);
+    }
+
     @Test(timeout = 15000)
     public void testLongWaitForResponse() {
         withReplicator(replicator -> {
@@ -580,10 +612,16 @@ public class TestThreadPoolRequestReplicator {
     }
 
     private void withReplicator(final WithReplicator function, final Status status, final long delayMillis, final RuntimeException failure) {
-        withReplicator(function, status, delayMillis, failure, "<>");
+        withReplicator(function, status, delayMillis, failure, "<>", "<>");
+    }
+
+    private void withReplicator(final WithReplicator function, final Status status, final long delayMillis, final RuntimeException failure,
+                                final String expectedRequestChain) {
+        withReplicator(function, status, delayMillis, failure, expectedRequestChain, "<>");
     }
 
-    private void withReplicator(final WithReplicator function, final Status status, final long delayMillis, final RuntimeException failure, final String expectedRequestChain) {
+    private void withReplicator(final WithReplicator function, final Status status, final long delayMillis, final RuntimeException failure,
+                                final String expectedRequestChain, final String expectedProxiedEntityGroups) {
         final ClusterCoordinator coordinator = createClusterCoordinator();
         final NiFiProperties nifiProps = NiFiProperties.createBasicNiFiProperties(null);
         final MockReplicationClient client = new MockReplicationClient();
@@ -607,11 +645,14 @@ public class TestThreadPoolRequestReplicator {
                     throw failure;
                 }
 
-                final Object proxiedEntities = request.getHeaders().get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
-
                 // ensure the request chain is in the request
+                final Object proxiedEntities = request.getHeaders().get(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
                 Assert.assertEquals(expectedRequestChain, proxiedEntities);
 
+                // ensure the proxied entity groups are in the request
+                final Object proxiedEntityGroups = request.getHeaders().get(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS);
+                Assert.assertEquals(expectedProxiedEntityGroups, proxiedEntityGroups);
+
                 // Return given response from all nodes.
                 final Response clientResponse = mock(Response.class);
                 when(clientResponse.getStatus()).thenReturn(status.getStatusCode());
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-mock-authorizer/src/main/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-mock-authorizer/src/main/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
index ccc068d..a7a41e2 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-mock-authorizer/src/main/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-mock-authorizer/src/main/java/org/apache/nifi/authorization/MockPolicyBasedAuthorizer.java
@@ -63,6 +63,11 @@ public class MockPolicyBasedAuthorizer extends AbstractPolicyBasedAuthorizer imp
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        return groups.stream().filter(g -> g.getName().equals(name)).findFirst().get();
+    }
+
+    @Override
     public Group doUpdateGroup(Group group) throws AuthorizationAccessException {
         deleteGroup(group);
         return addGroup(group);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
index 4c61311..2013d87 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/pom.xml
@@ -171,6 +171,23 @@
         <nifi.security.user.knox.cookieName>hadoop-jwt</nifi.security.user.knox.cookieName>
         <nifi.security.user.knox.audiences />
 
+        <!-- nifi.properties: saml -->
+        <nifi.security.user.saml.idp.metadata.url/>
+        <nifi.security.user.saml.sp.entity.id/>
+        <nifi.security.user.saml.identity.attribute.name/>
+        <nifi.security.user.saml.group.attribute.name/>
+        <nifi.security.user.saml.metadata.signing.enabled>false</nifi.security.user.saml.metadata.signing.enabled>
+        <nifi.security.user.saml.request.signing.enabled>false</nifi.security.user.saml.request.signing.enabled>
+        <nifi.security.user.saml.want.assertions.signed>true</nifi.security.user.saml.want.assertions.signed>
+        <nifi.security.user.saml.signature.algorithm>http://www.w3.org/2001/04/xmldsig-more#rsa-sha256</nifi.security.user.saml.signature.algorithm>
+        <nifi.security.user.saml.signature.digest.algorithm>http://www.w3.org/2001/04/xmlenc#sha256</nifi.security.user.saml.signature.digest.algorithm>
+        <nifi.security.user.saml.message.logging.enabled>false</nifi.security.user.saml.message.logging.enabled>
+        <nifi.security.user.saml.authentication.expiration>12 hours</nifi.security.user.saml.authentication.expiration>
+        <nifi.security.user.saml.single.logout.enabled>false</nifi.security.user.saml.single.logout.enabled>
+        <nifi.security.user.saml.http.client.truststore.strategy>JDK</nifi.security.user.saml.http.client.truststore.strategy>
+        <nifi.security.user.saml.http.client.read.timeout>30 secs</nifi.security.user.saml.http.client.read.timeout>
+        <nifi.security.user.saml.http.client.connect.timeout>30 secs</nifi.security.user.saml.http.client.connect.timeout>
+
         <!-- nifi.properties: cluster common properties (cluster manager and nodes must have same values) -->
         <nifi.cluster.protocol.heartbeat.interval>5 sec</nifi.cluster.protocol.heartbeat.interval>
         <nifi.cluster.protocol.heartbeat.missable.max>8</nifi.cluster.protocol.heartbeat.missable.max>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
index c6ddcbe..9172009 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/logback.xml
@@ -18,7 +18,7 @@
     <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator">
         <resetJUL>true</resetJUL>
     </contextListener>
-    
+
     <appender name="APP_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${org.apache.nifi.bootstrap.config.log.dir}/nifi-app.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
@@ -38,7 +38,7 @@
             <pattern>%date %level [%thread] %logger{40} %msg%n</pattern>
         </encoder>
     </appender>
-    
+
     <appender name="USER_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
         <file>${org.apache.nifi.bootstrap.config.log.dir}/nifi-user.log</file>
         <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
@@ -74,22 +74,22 @@
             <pattern>%date %level [%thread] %logger{40} %msg%n</pattern>
         </encoder>
     </appender>
-	
+
     <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
         <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
             <pattern>%date %level [%thread] %logger{40} %msg%n</pattern>
         </encoder>
     </appender>
-    
+
     <!-- valid logging levels: TRACE, DEBUG, INFO, WARN, ERROR -->
-    
+
     <logger name="org.apache.nifi" level="INFO"/>
     <logger name="org.apache.nifi.processors" level="WARN"/>
     <logger name="org.apache.nifi.processors.standard.LogAttribute" level="INFO"/>
     <logger name="org.apache.nifi.processors.standard.LogMessage" level="INFO"/>
     <logger name="org.apache.nifi.controller.repository.StandardProcessSession" level="WARN" />
-    
-    
+
+
     <logger name="org.apache.zookeeper.ClientCnxn" level="ERROR" />
     <logger name="org.apache.zookeeper.server.NIOServerCnxn" level="ERROR" />
     <logger name="org.apache.zookeeper.server.NIOServerCnxnFactory" level="ERROR" />
@@ -101,7 +101,7 @@
 
     <logger name="org.apache.curator.framework.recipes.leader.LeaderSelector" level="OFF" />
     <logger name="org.apache.curator.ConnectionState" level="OFF" />
-    
+
     <!-- Logger for managing logging statements for nifi clusters. -->
     <logger name="org.apache.nifi.cluster" level="INFO"/>
 
@@ -113,7 +113,7 @@
 
     <!-- Suppress non-error messages due to excessive logging by class or library -->
     <logger name="org.springframework" level="ERROR"/>
-    
+
     <!-- Suppress non-error messages due to known warning about redundant path annotation (NIFI-574) -->
     <logger name="org.glassfish.jersey.internal.Errors" level="ERROR"/>
 
@@ -155,10 +155,16 @@
     <logger name="org.apache.nifi.web.api.AccessResource" level="INFO" additivity="false">
         <appender-ref ref="USER_FILE"/>
     </logger>
+    <logger name="org.springframework.security.saml.log" level="WARN" additivity="false">
+        <appender-ref ref="USER_FILE"/>
+    </logger>
+    <logger name="org.opensaml" level="WARN" additivity="false">
+        <appender-ref ref="USER_FILE"/>
+    </logger>
 
 
     <!--
-        Logger for capturing Bootstrap logs and NiFi's standard error and standard out. 
+        Logger for capturing Bootstrap logs and NiFi's standard error and standard out.
     -->
     <logger name="org.apache.nifi.bootstrap" level="INFO" additivity="false">
         <appender-ref ref="BOOTSTRAP_FILE" />
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
index e394c86..be45e4a 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-resources/src/main/resources/conf/nifi.properties
@@ -185,6 +185,23 @@ nifi.security.user.knox.publicKey=${nifi.security.user.knox.publicKey}
 nifi.security.user.knox.cookieName=${nifi.security.user.knox.cookieName}
 nifi.security.user.knox.audiences=${nifi.security.user.knox.audiences}
 
+# SAML Properties #
+nifi.security.user.saml.idp.metadata.url=${nifi.security.user.saml.idp.metadata.url}
+nifi.security.user.saml.sp.entity.id=${nifi.security.user.saml.sp.entity.id}
+nifi.security.user.saml.identity.attribute.name=${nifi.security.user.saml.identity.attribute.name}
+nifi.security.user.saml.group.attribute.name=${nifi.security.user.saml.group.attribute.name}
+nifi.security.user.saml.metadata.signing.enabled=${nifi.security.user.saml.metadata.signing.enabled}
+nifi.security.user.saml.request.signing.enabled=${nifi.security.user.saml.request.signing.enabled}
+nifi.security.user.saml.want.assertions.signed=${nifi.security.user.saml.want.assertions.signed}
+nifi.security.user.saml.signature.algorithm=${nifi.security.user.saml.signature.algorithm}
+nifi.security.user.saml.signature.digest.algorithm=${nifi.security.user.saml.signature.digest.algorithm}
+nifi.security.user.saml.message.logging.enabled=${nifi.security.user.saml.message.logging.enabled}
+nifi.security.user.saml.authentication.expiration=${nifi.security.user.saml.authentication.expiration}
+nifi.security.user.saml.single.logout.enabled=${nifi.security.user.saml.single.logout.enabled}
+nifi.security.user.saml.http.client.truststore.strategy=${nifi.security.user.saml.http.client.truststore.strategy}
+nifi.security.user.saml.http.client.connect.timeout=${nifi.security.user.saml.http.client.connect.timeout}
+nifi.security.user.saml.http.client.read.timeout=${nifi.security.user.saml.http.client.read.timeout}
+
 # Identity Mapping Properties #
 # These properties allow normalizing user identities such that identities coming from different identity providers
 # (certificates, LDAP, Kerberos) can be treated the same internally in NiFi. The following example demonstrates normalizing
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
index 6ad35dc..e8e793c 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-shell-authorizer/src/main/java/org/apache/nifi/authorization/ShellUserGroupProvider.java
@@ -52,6 +52,7 @@ public class ShellUserGroupProvider implements UserGroupProvider {
     private final static Map<String, User> usersById = new HashMap<>();   // id == identifier
     private final static Map<String, User> usersByName = new HashMap<>(); // name == identity
     private final static Map<String, Group> groupsById = new HashMap<>();
+    private final static Map<String, Group> groupsByName = new HashMap<>();
 
     public static final String REFRESH_DELAY_PROPERTY = "Refresh Delay";
     private static final long MINIMUM_SYNC_INTERVAL_MILLISECONDS = 10_000;
@@ -182,12 +183,29 @@ public class ShellUserGroupProvider implements UserGroupProvider {
         if (group == null) {
             logger.debug("getGroup (by id) group not found: " + identifier);
         } else {
-            logger.debug("getGroup (by id) found group: " + group.getName() + " for id: " + identifier);
+            logger.debug("getGroup (by id) found group: {} for id: {}", group.getName(), identifier);
         }
         return group;
 
     }
 
+    @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        Group group;
+
+        synchronized (groupsByName) {
+            group = groupsByName.get(name);
+        }
+
+        if (group == null) {
+            logger.debug("getGroup (by name) group not found: " + name);
+        } else {
+            logger.debug("getGroup (by name) found group: {} for name: {}", group.getName(), name);
+        }
+
+        return group;
+    }
+
     /**
      * Gets a user and their groups.
      *
@@ -452,6 +470,9 @@ public class ShellUserGroupProvider implements UserGroupProvider {
                 synchronized (groupsById) {
                     groupsById.putAll(gidToGroup);
                 }
+                synchronized (groupsByName) {
+                    gidToGroup.values().forEach(g -> groupsByName.put(g.getName(), g));
+                }
             }
         } else {
             logger.info("Get Single Group not supported on this system.");
@@ -507,7 +528,7 @@ public class ShellUserGroupProvider implements UserGroupProvider {
         synchronized (groupsById) {
             groupsById.clear();
             groupsById.putAll(gidToGroup);
-            logger.debug("groups now size: " + groupsById.size());
+            logger.debug("groupsById now size: " + groupsById.size());
 
             if (logger.isTraceEnabled()) {
                 logger.trace("=== Groups by id...");
@@ -517,6 +538,12 @@ public class ShellUserGroupProvider implements UserGroupProvider {
             }
         }
 
+        synchronized (groupsByName) {
+            groupsByName.clear();
+            gidToGroup.values().forEach(g -> groupsByName.put(g.getName(), g));
+            logger.debug("groupsByName now size: " + groupsByName.size());
+        }
+
         final long endTime = System.currentTimeMillis();
         logger.info("Refreshed users and groups, took {} seconds", (endTime - startTime) / 1000);
     }
@@ -700,6 +727,10 @@ public class ShellUserGroupProvider implements UserGroupProvider {
         synchronized (groupsById) {
             groupsById.clear();
         }
+
+        synchronized (groupsByName) {
+            groupsByName.clear();
+        }
     }
 
     /**
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
index 0fde717..d2c50dd 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-jetty/src/main/java/org/apache/nifi/web/server/JettyServer.java
@@ -18,37 +18,6 @@ package org.apache.nifi.web.server;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import java.io.BufferedReader;
-import java.io.BufferedWriter;
-import java.io.File;
-import java.io.FileFilter;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.net.InetAddress;
-import java.net.NetworkInterface;
-import java.net.SocketException;
-import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.EnumSet;
-import java.util.Enumeration;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.stream.Collectors;
-import javax.servlet.DispatcherType;
-import javax.servlet.Filter;
-import javax.servlet.ServletContext;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.NiFiServer;
@@ -121,6 +90,38 @@ import org.springframework.context.ApplicationContext;
 import org.springframework.web.context.WebApplicationContext;
 import org.springframework.web.context.support.WebApplicationContextUtils;
 
+import javax.servlet.DispatcherType;
+import javax.servlet.Filter;
+import javax.servlet.ServletContext;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.net.SocketException;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.stream.Collectors;
+
 /**
  * Encapsulates the Jetty instance.
  */
@@ -276,6 +277,8 @@ public class JettyServer implements NiFiServer, ExtensionUiLoader {
         final WebAppContext webUiContext = loadWar(webUiWar, "/nifi", frameworkClassLoader);
         webUiContext.getInitParams().put("oidc-supported", String.valueOf(props.isOidcEnabled()));
         webUiContext.getInitParams().put("knox-supported", String.valueOf(props.isKnoxSsoEnabled()));
+        webUiContext.getInitParams().put("saml-supported", String.valueOf(props.isSamlEnabled()));
+        webUiContext.getInitParams().put("saml-single-logout-supported", String.valueOf(props.isSamlSingleLogoutEnabled()));
         webUiContext.getInitParams().put("allowedContextPaths", props.getAllowedContextPaths());
         webAppContextHandlers.addHandler(webUiContext);
 
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
index 7aee52b..7403185 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiWebApiSecurityConfiguration.java
@@ -26,6 +26,7 @@ import org.apache.nifi.web.security.knox.KnoxAuthenticationFilter;
 import org.apache.nifi.web.security.knox.KnoxAuthenticationProvider;
 import org.apache.nifi.web.security.otp.OtpAuthenticationFilter;
 import org.apache.nifi.web.security.otp.OtpAuthenticationProvider;
+import org.apache.nifi.web.security.saml.SAMLEndpoints;
 import org.apache.nifi.web.security.x509.X509AuthenticationFilter;
 import org.apache.nifi.web.security.x509.X509AuthenticationProvider;
 import org.apache.nifi.web.security.x509.X509CertificateExtractor;
@@ -90,9 +91,27 @@ public class NiFiWebApiSecurityConfiguration extends WebSecurityConfigurerAdapte
         // the /access/download-token and /access/ui-extension-token endpoints
         webSecurity
                 .ignoring()
-                    .antMatchers("/access", "/access/config", "/access/token", "/access/kerberos",
-                            "/access/oidc/exchange", "/access/oidc/callback", "/access/oidc/logoutCallback", "/access/oidc/request",
-                            "/access/knox/callback", "/access/knox/request");
+                    .antMatchers(
+                            "/access",
+                            "/access/config",
+                            "/access/token",
+                            "/access/kerberos",
+                            "/access/oidc/exchange",
+                            "/access/oidc/callback",
+                            "/access/oidc/logoutCallback",
+                            "/access/oidc/request",
+                            "/access/knox/callback",
+                            "/access/knox/request",
+                            SAMLEndpoints.SERVICE_PROVIDER_METADATA,
+                            SAMLEndpoints.LOGIN_REQUEST,
+                            SAMLEndpoints.LOGIN_CONSUMER,
+                            SAMLEndpoints.LOGIN_EXCHANGE,
+                            // the logout sequence will be protected by a request identifier set in a Cookie so these
+                            // paths need to be listed here in order to pass through our normal authn filters
+                            SAMLEndpoints.SINGLE_LOGOUT_REQUEST,
+                            SAMLEndpoints.SINGLE_LOGOUT_CONSUMER,
+                            SAMLEndpoints.LOCAL_LOGOUT,
+                            "/access/logout/complete");
     }
 
     @Override
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index 929580d..828c800 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -3725,7 +3725,7 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
         final AuthorizationRequest request = new AuthorizationRequest.Builder()
                 .resource(ResourceFactory.getDataTransferResource(port.getResource()))
                 .identity(user.getIdentity())
-                .groups(user.getGroups())
+                .groups(user.getAllGroups())
                 .anonymous(user.isAnonymous())
                 .accessAttempt(false)
                 .action(RequestAction.WRITE)
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
index 32e1b22..db38204 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/AccessResource.java
@@ -30,30 +30,6 @@ import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiResponse;
 import io.swagger.annotations.ApiResponses;
-import java.io.IOException;
-import java.net.URI;
-import java.security.cert.X509Certificate;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.UUID;
-import java.util.concurrent.TimeUnit;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.servlet.ServletContext;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.ws.rs.Consumes;
-import javax.ws.rs.DELETE;
-import javax.ws.rs.FormParam;
-import javax.ws.rs.GET;
-import javax.ws.rs.POST;
-import javax.ws.rs.Path;
-import javax.ws.rs.Produces;
-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 org.apache.commons.lang3.StringUtils;
 import org.apache.http.NameValuePair;
 import org.apache.http.client.config.RequestConfig;
@@ -64,6 +40,7 @@ import org.apache.http.impl.client.CloseableHttpClient;
 import org.apache.http.impl.client.HttpClientBuilder;
 import org.apache.http.message.BasicNameValuePair;
 import org.apache.nifi.admin.service.AdministrationException;
+import org.apache.nifi.admin.service.IdpUserGroupService;
 import org.apache.nifi.authentication.AuthenticationResponse;
 import org.apache.nifi.authentication.LoginCredentials;
 import org.apache.nifi.authentication.LoginIdentityProvider;
@@ -74,7 +51,9 @@ import org.apache.nifi.authorization.AccessDeniedException;
 import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
 import org.apache.nifi.authorization.user.NiFiUserUtils;
+import org.apache.nifi.authorization.util.IdentityMapping;
 import org.apache.nifi.authorization.util.IdentityMappingUtil;
+import org.apache.nifi.idp.IdpType;
 import org.apache.nifi.util.FormatUtils;
 import org.apache.nifi.web.api.dto.AccessConfigurationDTO;
 import org.apache.nifi.web.api.dto.AccessStatusDTO;
@@ -89,21 +68,60 @@ import org.apache.nifi.web.security.jwt.JwtAuthenticationRequestToken;
 import org.apache.nifi.web.security.jwt.JwtService;
 import org.apache.nifi.web.security.kerberos.KerberosService;
 import org.apache.nifi.web.security.knox.KnoxService;
+import org.apache.nifi.web.security.logout.LogoutRequest;
+import org.apache.nifi.web.security.logout.LogoutRequestManager;
 import org.apache.nifi.web.security.oidc.OidcService;
 import org.apache.nifi.web.security.otp.OtpService;
+import org.apache.nifi.web.security.saml.SAMLCredentialStore;
+import org.apache.nifi.web.security.saml.SAMLEndpoints;
+import org.apache.nifi.web.security.saml.SAMLService;
+import org.apache.nifi.web.security.saml.SAMLStateManager;
 import org.apache.nifi.web.security.token.LoginAuthenticationToken;
 import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
 import org.apache.nifi.web.security.token.OtpAuthenticationToken;
 import org.apache.nifi.web.security.x509.X509AuthenticationProvider;
 import org.apache.nifi.web.security.x509.X509AuthenticationRequestToken;
 import org.apache.nifi.web.security.x509.X509CertificateExtractor;
+import org.opensaml.saml2.metadata.provider.MetadataProviderException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.security.authentication.AuthenticationServiceException;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.saml.SAMLCredential;
 import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor;
 
+import javax.servlet.ServletContext;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.FormParam;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+import java.io.IOException;
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
 /**
  * RESTful endpoint for managing access.
  */
@@ -117,7 +135,6 @@ public class AccessResource extends ApplicationResource {
     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 static final String OIDC_ID_TOKEN_AUTHN_ERROR = "Unable to exchange authorization for ID token: ";
     private static final String OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG = "OpenId Connect support is not configured";
     private static final String REVOKE_ACCESS_TOKEN_LOGOUT = "oidc_access_token_logout";
@@ -127,7 +144,17 @@ public class AccessResource extends ApplicationResource {
     private static final Pattern ID_TOKEN_LOGOUT_FORMAT = Pattern.compile("(\\.okta)");
     private static final int msTimeout = 30_000;
 
+    private static final String SAML_REQUEST_IDENTIFIER = "saml-request-identifier";
+    private static final String SAML_METADATA_MEDIA_TYPE = "application/samlmetadata+xml";
+
+    private static final String LOGIN_ERROR_TITLE = "Unable to continue login sequence";
+    private static final String LOGOUT_ERROR_TITLE = "Unable to continue logout sequence";
+    private static final String LOGOUT_REQUEST_IDENTIFIER = "nifi-logout-request-identifier";
+
+
     private static final String AUTHENTICATION_NOT_ENABLED_MSG = "User authentication/authorization is only supported when running over HTTPS.";
+    private static final String LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND = "The logout request identifier was not found in the request. Unable to continue.";
+    private static final String LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER = "No logout request was found for the given identifier. Unable to continue.";
 
     private X509CertificateExtractor certificateExtractor;
     private X509AuthenticationProvider x509AuthenticationProvider;
@@ -139,9 +166,15 @@ public class AccessResource extends ApplicationResource {
     private OtpService otpService;
     private OidcService oidcService;
     private KnoxService knoxService;
-
     private KerberosService kerberosService;
 
+    private SAMLService samlService;
+    private SAMLStateManager samlStateManager;
+    private SAMLCredentialStore samlCredentialStore;
+
+    private IdpUserGroupService idpUserGroupService;
+    private LogoutRequestManager logoutRequestManager;
+
     /**
      * Retrieves the access configuration for this NiFi.
      *
@@ -173,6 +206,485 @@ public class AccessResource extends ApplicationResource {
 
     @GET
     @Consumes(MediaType.WILDCARD)
+    @Produces(SAML_METADATA_MEDIA_TYPE)
+    @Path(SAMLEndpoints.SERVICE_PROVIDER_METADATA_RELATIVE)
+    @ApiOperation(
+            value = "Retrieves the service provider metadata.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public Response samlMetadata(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            logger.warn(SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return Response.status(Response.Status.CONFLICT).entity(SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED).build();
+        }
+
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        final String metadataXml = samlService.getServiceProviderMetadata();
+        return Response.ok(metadataXml, SAML_METADATA_MEDIA_TYPE).build();
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.LOGIN_REQUEST_RELATIVE)
+    @ApiOperation(
+            value = "Initiates an SSO request to the configured SAML identity provider.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlLoginRequest(@Context HttpServletRequest httpServletRequest,
+                                 @Context HttpServletResponse httpServletResponse) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            return;
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        final String samlRequestIdentifier = UUID.randomUUID().toString();
+
+        // generate a cookie to associate this login sequence
+        final Cookie cookie = new Cookie(SAML_REQUEST_IDENTIFIER, samlRequestIdentifier);
+        cookie.setPath("/");
+        cookie.setHttpOnly(true);
+        cookie.setMaxAge(60);
+        cookie.setSecure(true);
+        httpServletResponse.addCookie(cookie);
+
+        // get the state for this request
+        final String relayState = samlStateManager.createState(samlRequestIdentifier);
+
+        // initiate the login request
+        try {
+            samlService.initiateLogin(httpServletRequest, httpServletResponse, relayState);
+        } catch (Exception e) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, e.getMessage());
+            return;
+        }
+    }
+
+    @POST
+    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.LOGIN_CONSUMER_RELATIVE)
+    @ApiOperation(
+            value = "Processes the SSO response from the SAML identity provider for HTTP-POST binding.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlLoginHttpPostConsumer(@Context HttpServletRequest httpServletRequest,
+                                          @Context HttpServletResponse httpServletResponse,
+                                          MultivaluedMap<String, String> formParams) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            return;
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // process the response from the idp...
+        final Map<String, String> parameters = getParameterMap(formParams);
+        samlLoginConsumer(httpServletRequest, httpServletResponse, parameters);
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.LOGIN_CONSUMER_RELATIVE)
+    @ApiOperation(
+            value = "Processes the SSO response from the SAML identity provider for HTTP-REDIRECT binding.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlLoginHttpRedirectConsumer(@Context HttpServletRequest httpServletRequest,
+                                              @Context HttpServletResponse httpServletResponse,
+                                              @Context UriInfo uriInfo) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            return;
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // process the response from the idp...
+        final Map<String, String> parameters = getParameterMap(uriInfo.getQueryParameters());
+        samlLoginConsumer(httpServletRequest, httpServletResponse, parameters);
+    }
+
+    private void samlLoginConsumer(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Map<String, String> parameters) throws Exception {
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        // ensure the request has the cookie with the request id
+        final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
+        if (samlRequestIdentifier == null) {
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was not found in the request. Unable to continue.");
+            return;
+        }
+
+        // ensure a RelayState value was sent back
+        final String requestState = parameters.get("RelayState");
+        if (requestState == null) {
+            removeSamlRequestCookie(httpServletResponse);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The RelayState parameter was not found in the request. Unable to continue.");
+            return;
+        }
+
+        // ensure the RelayState value in the request matches the store state
+        if (!samlStateManager.isStateValid(samlRequestIdentifier, requestState)) {
+            logger.error("The RelayState value returned by the SAML IDP does not match the stored state. Unable to continue login process.");
+            removeSamlRequestCookie(httpServletResponse);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Purposed RelayState does not match the stored state. Unable to continue login process.");
+            return;
+        }
+
+        // process the SAML response
+        final SAMLCredential samlCredential;
+        try {
+            samlCredential = samlService.processLogin(httpServletRequest, httpServletResponse, parameters);
+        } catch (Exception e) {
+            removeSamlRequestCookie(httpServletResponse);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, e.getMessage());
+            return;
+        }
+
+        // create the login token
+        final String rawIdentity = samlService.getUserIdentity(samlCredential);
+        final String mappedIdentity = IdentityMappingUtil.mapIdentity(rawIdentity, IdentityMappingUtil.getIdentityMappings(properties));
+        final long expiration = validateTokenExpiration(samlService.getAuthExpiration(), mappedIdentity);
+        final String issuer = samlCredential.getRemoteEntityID();
+
+        final LoginAuthenticationToken loginToken = new LoginAuthenticationToken(mappedIdentity, mappedIdentity, expiration, issuer);
+
+        // create and cache a NiFi JWT that can be retrieved later from the exchange end-point
+        samlStateManager.createJwt(samlRequestIdentifier, loginToken);
+
+        // store the SAMLCredential for retrieval during logout
+        samlCredentialStore.save(mappedIdentity, samlCredential);
+
+        // get the user's groups from the assertions if the exist and store them for later retrieval
+        final Set<String> userGroups = samlService.getUserGroups(samlCredential);
+        if (logger.isDebugEnabled()) {
+            logger.debug("SAML User '{}' belongs to the unmapped groups {}", mappedIdentity, StringUtils.join(userGroups));
+        }
+
+        final List<IdentityMapping> groupIdentityMappings = IdentityMappingUtil.getGroupMappings(properties);
+        final Set<String> mappedGroups = userGroups.stream()
+                .map(g -> IdentityMappingUtil.mapIdentity(g, groupIdentityMappings))
+                .collect(Collectors.toSet());
+        logger.info("SAML User '{}' belongs to the mapped groups {}", mappedIdentity, StringUtils.join(mappedGroups));
+
+        idpUserGroupService.replaceUserGroups(mappedIdentity, IdpType.SAML, mappedGroups);
+
+        // redirect to the name page
+        httpServletResponse.sendRedirect(getNiFiUri());
+    }
+
+    @POST
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_PLAIN)
+    @Path(SAMLEndpoints.LOGIN_EXCHANGE_RELATIVE)
+    @ApiOperation(
+            value = "Retrieves a JWT following a successful login sequence using the configured SAML identity provider.",
+            response = String.class,
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public Response samlLoginExchange(@Context HttpServletRequest httpServletRequest,
+                                      @Context HttpServletResponse httpServletResponse) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            logger.warn(SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return Response.status(Response.Status.CONFLICT).entity(SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED).build();
+        }
+
+        logger.info("Attempting to exchange SAML login request for a NiFi JWT...");
+
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        // ensure the request has the cookie with the request identifier
+        final String samlRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), SAML_REQUEST_IDENTIFIER);
+        if (samlRequestIdentifier == null) {
+            final String message = "The login request identifier was not found in the request. Unable to continue.";
+            logger.warn(message);
+            return Response.status(Response.Status.BAD_REQUEST).entity(message).build();
+        }
+
+        // remove the saml request cookie
+        removeSamlRequestCookie(httpServletResponse);
+
+        // get the jwt
+        final String jwt = samlStateManager.getJwt(samlRequestIdentifier);
+        if (jwt == null) {
+            throw new IllegalArgumentException("A JWT for this login request identifier could not be found. Unable to continue.");
+        }
+
+        // generate the response
+        logger.info("SAML login exchange complete");
+        return generateOkResponse(jwt).build();
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.SINGLE_LOGOUT_REQUEST_RELATIVE)
+    @ApiOperation(
+            value = "Initiates a logout request using the SingleLogout service of the configured SAML identity provider.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlSingleLogoutRequest(@Context HttpServletRequest httpServletRequest,
+                                        @Context HttpServletResponse httpServletResponse) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // ensure the logout request identifier is present
+        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        if (StringUtils.isBlank(logoutRequestIdentifier)) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
+            return;
+        }
+
+        // ensure there is a logout request in progress for the given identifier
+        final LogoutRequest logoutRequest = logoutRequestManager.get(logoutRequestIdentifier);
+        if (logoutRequest == null) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER);
+            return;
+        }
+
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        final String userIdentity = logoutRequest.getMappedUserIdentity();
+        logger.info("Attempting to performing SAML Single Logout for {}", userIdentity);
+
+        // retrieve the credential that was stored during the login sequence
+        final SAMLCredential samlCredential = samlCredentialStore.get(userIdentity);
+        if (samlCredential == null) {
+            throw new IllegalStateException("Unable to find a stored SAML credential for " + userIdentity);
+        }
+
+        // initiate the logout
+        try {
+            logger.info("Initiating SAML Single Logout with IDP...");
+            samlService.initiateLogout(httpServletRequest, httpServletResponse, samlCredential);
+        } catch (Exception e) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, e.getMessage());
+            return;
+        }
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.SINGLE_LOGOUT_CONSUMER_RELATIVE)
+    @ApiOperation(
+            value = "Processes a SingleLogout message from the configured SAML identity provider using the HTTP-REDIRECT binding.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlSingleLogoutHttpRedirectConsumer(@Context HttpServletRequest httpServletRequest,
+                                                     @Context HttpServletResponse httpServletResponse,
+                                                     @Context UriInfo uriInfo) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // process the SLO request
+        final Map<String, String> parameters = getParameterMap(uriInfo.getQueryParameters());
+        samlSingleLogoutConsumer(httpServletRequest, httpServletResponse, parameters);
+    }
+
+    @POST
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.SINGLE_LOGOUT_CONSUMER_RELATIVE)
+    @ApiOperation(
+            value = "Processes a SingleLogout message from the configured SAML identity provider using the HTTP-POST binding.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlSingleLogoutHttpPostConsumer(@Context HttpServletRequest httpServletRequest,
+                                                 @Context HttpServletResponse httpServletResponse,
+                                                 MultivaluedMap<String, String> formParams) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // process the SLO request
+        final Map<String, String> parameters = getParameterMap(formParams);
+        samlSingleLogoutConsumer(httpServletRequest, httpServletResponse, parameters);
+    }
+
+    /**
+     * Common logic for consuming SAML Single Logout messages from either HTTP-POST or HTTP-REDIRECT.
+     *
+     * @param httpServletRequest the request
+     * @param httpServletResponse the response
+     * @param parameters additional parameters
+     * @throws Exception if an error occurs
+     */
+    private void samlSingleLogoutConsumer(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
+                                          Map<String, String> parameters) throws Exception {
+
+        // ensure saml service provider is initialized
+        initializeSamlServiceProvider();
+
+        // ensure the logout request identifier is present
+        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        if (StringUtils.isBlank(logoutRequestIdentifier)) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_IDENTIFIER_NOT_FOUND);
+            return;
+        }
+
+        // ensure there is a logout request in progress for the given identifier
+        final LogoutRequest logoutRequest = logoutRequestManager.get(logoutRequestIdentifier);
+        if (logoutRequest == null) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, LOGOUT_REQUEST_NOT_FOUND_FOR_GIVEN_IDENTIFIER);
+            return;
+        }
+
+        // complete the logout request so it is no longer cached
+        logoutRequestManager.complete(logoutRequestIdentifier);
+
+        // remove the cookie with the logout request identifier
+        removeLogoutRequestCookie(httpServletResponse);
+
+        // get the user identity from the logout request
+        final String identity = logoutRequest.getMappedUserIdentity();
+        logger.info("Consuming SAML Single Logout for {}", identity);
+
+        // remove the saved credential
+        samlCredentialStore.delete(identity);
+
+        // delete any stored groups
+        idpUserGroupService.deleteUserGroups(identity);
+
+        // process the Single Logout SAML message
+        try {
+            samlService.processLogout(httpServletRequest, httpServletResponse, parameters);
+            logger.info("Completed SAML Single Logout for {}", identity);
+        } catch (Exception e) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, e.getMessage());
+            return;
+        }
+
+        // redirect to the logout landing page
+        httpServletResponse.sendRedirect(getNiFiLogoutCompleteUri());
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path(SAMLEndpoints.LOCAL_LOGOUT_RELATIVE)
+    @ApiOperation(
+            value = "Local logout when SAML is enabled, does not communicate with the IDP.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    public void samlLocalLogout(@Context HttpServletRequest httpServletRequest,
+                                @Context HttpServletResponse httpServletResponse) throws Exception {
+
+        // only consider user specific access over https
+        if (!httpServletRequest.isSecure()) {
+            throw new AuthenticationNotSupportedException(AUTHENTICATION_NOT_ENABLED_MSG);
+        }
+
+        // ensure saml is enabled
+        if (!samlService.isSamlEnabled()) {
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, SAMLService.SAML_SUPPORT_IS_NOT_CONFIGURED);
+            return;
+        }
+
+        // complete the logout request if one exists
+        final LogoutRequest completedLogoutRequest = completeLogoutRequest(httpServletResponse);
+
+        // if a logout request was completed, then delete the stored SAMLCredential for that user
+        if (completedLogoutRequest != null) {
+            final String userIdentity = completedLogoutRequest.getMappedUserIdentity();
+
+            logger.info("Removing cached SAML information for " + userIdentity);
+            samlCredentialStore.delete(userIdentity);
+
+            logger.info("Removing cached SAML Groups for " + userIdentity);
+            idpUserGroupService.deleteUserGroups(userIdentity);
+        }
+
+        // redirect to logout landing page
+        httpServletResponse.sendRedirect(getNiFiLogoutCompleteUri());
+    }
+
+    private void initializeSamlServiceProvider() throws MetadataProviderException {
+        if (!samlService.isServiceProviderInitialized()) {
+            final String samlMetadataUri = generateResourceUri("saml", "metadata");
+            final String baseUri = samlMetadataUri.replace("/saml/metadata", "");
+            samlService.initializeServiceProvider(baseUri);
+        }
+    }
+
+    private Map<String,String> getParameterMap(final MultivaluedMap<String, String> formParams) {
+        final Map<String,String> params = new HashMap<>();
+        for (final String paramKey : formParams.keySet()) {
+            params.put(paramKey, formParams.getFirst(paramKey));
+        }
+        return params;
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
     @Produces(MediaType.WILDCARD)
     @Path("oidc/request")
     @ApiOperation(
@@ -182,13 +694,13 @@ public class AccessResource extends ApplicationResource {
     public void oidcRequest(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
         // only consider user specific access over https
         if (!httpServletRequest.isSecure()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
             return;
         }
 
         // ensure oidc is enabled
         if (!oidcService.isOidcEnabled()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
             return;
         }
 
@@ -210,19 +722,19 @@ public class AccessResource extends ApplicationResource {
     public void oidcCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
         // only consider user specific access over https
         if (!httpServletRequest.isSecure()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
             return;
         }
 
         // ensure oidc is enabled
         if (!oidcService.isOidcEnabled()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
             return;
         }
 
         final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
         if (oidcRequestIdentifier == null) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
                     "not found in the request. Unable to continue.");
             return;
         }
@@ -237,7 +749,7 @@ public class AccessResource extends ApplicationResource {
             removeOidcRequestCookie(httpServletResponse);
 
             // forward to the error page
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " +
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " +
                     "from the OpenId Connect Provider. Unable to continue login process.");
             return;
         }
@@ -255,7 +767,7 @@ public class AccessResource extends ApplicationResource {
                 removeOidcRequestCookie(httpServletResponse);
 
                 // forward to the error page
-                forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " +
+                forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " +
                         "the stored state. Unable to continue login process.");
                 return;
             }
@@ -281,7 +793,7 @@ public class AccessResource extends ApplicationResource {
                 removeOidcRequestCookie(httpServletResponse);
 
                 // forward to the error page
-                forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
+                forwardToLoginMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
                 return;
             }
 
@@ -293,7 +805,7 @@ public class AccessResource extends ApplicationResource {
 
             // report the unsuccessful login
             final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse;
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: "
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful login attempt: "
                     + errorOidcResponse.getErrorObject().getDescription());
         }
     }
@@ -315,12 +827,15 @@ public class AccessResource extends ApplicationResource {
 
         // ensure oidc is enabled
         if (!oidcService.isOidcEnabled()) {
-            throw new IllegalStateException(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
+            logger.warn(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
+            return Response.status(Response.Status.CONFLICT).entity(OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG).build();
         }
 
         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.");
+            final String message = "The login request identifier was not found in the request. Unable to continue.";
+            logger.warn(message);
+            return Response.status(Response.Status.BAD_REQUEST).entity(message).build();
         }
 
         // remove the oidc request cookie
@@ -395,19 +910,19 @@ public class AccessResource extends ApplicationResource {
     public void oidcLogoutCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
         // only consider user specific access over https
         if (!httpServletRequest.isSecure()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
             return;
         }
 
         // ensure oidc is enabled
         if (!oidcService.isOidcEnabled()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED_MSG);
             return;
         }
 
         final String oidcRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), OIDC_REQUEST_IDENTIFIER);
         if (oidcRequestIdentifier == null) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "The login request identifier was " +
                     "not found in the request. Unable to continue.");
             return;
         }
@@ -423,7 +938,7 @@ public class AccessResource extends ApplicationResource {
             removeOidcRequestCookie(httpServletResponse);
 
             // forward to the error page
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " +
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "Unable to parse the redirect URI " +
                     "from the OpenId Connect Provider. Unable to continue logout process.");
             return;
         }
@@ -441,7 +956,7 @@ public class AccessResource extends ApplicationResource {
                 removeOidcRequestCookie(httpServletResponse);
 
                 // forward to the error page
-                forwardToMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " +
+                forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "Purposed state does not match " +
                         "the stored state. Unable to continue login process.");
                 return;
             }
@@ -470,7 +985,7 @@ public class AccessResource extends ApplicationResource {
                         removeOidcRequestCookie(httpServletResponse);
 
                         // Forward to the error page
-                        forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
+                        forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
                         return;
                     }
 
@@ -490,7 +1005,7 @@ public class AccessResource extends ApplicationResource {
                             removeOidcRequestCookie(httpServletResponse);
 
                             // Forward to the error page
-                            forwardToMessagePage(httpServletRequest, httpServletResponse,
+                            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse,
                                     "There was an error logging out of the OpenId Connect Provider: "
                                             + e.getMessage());
                         }
@@ -509,7 +1024,7 @@ public class AccessResource extends ApplicationResource {
                         removeOidcRequestCookie(httpServletResponse);
 
                         // Forward to the error page
-                        forwardToMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
+                        forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, OIDC_ID_TOKEN_AUTHN_ERROR + e.getMessage());
                         return;
                     }
 
@@ -536,7 +1051,7 @@ public class AccessResource extends ApplicationResource {
 
             // report the unsuccessful logout
             final AuthenticationErrorResponse errorOidcResponse = (AuthenticationErrorResponse) oidcResponse;
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful logout attempt: "
+            forwardToLogoutMessagePage(httpServletRequest, httpServletResponse, "Unsuccessful logout attempt: "
                     + errorOidcResponse.getErrorObject().getDescription());
         }
     }
@@ -552,13 +1067,13 @@ public class AccessResource extends ApplicationResource {
     public void knoxRequest(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
         // only consider user specific access over https
         if (!httpServletRequest.isSecure()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
             return;
         }
 
         // ensure knox is enabled
         if (!knoxService.isKnoxEnabled()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Apache Knox SSO support is not configured.");
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Apache Knox SSO support is not configured.");
             return;
         }
 
@@ -585,13 +1100,13 @@ public class AccessResource extends ApplicationResource {
     public void knoxCallback(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
         // only consider user specific access over https
         if (!httpServletRequest.isSecure()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, AUTHENTICATION_NOT_ENABLED_MSG);
             return;
         }
 
         // ensure knox is enabled
         if (!knoxService.isKnoxEnabled()) {
-            forwardToMessagePage(httpServletRequest, httpServletResponse, "Apache Knox SSO support is not configured.");
+            forwardToLoginMessagePage(httpServletRequest, httpServletResponse, "Apache Knox SSO support is not configured.");
             return;
         }
 
@@ -677,8 +1192,11 @@ public class AccessResource extends ApplicationResource {
                 }
             } else {
                 try {
+                    final String proxiedEntitiesChain = httpServletRequest.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
+                    final String proxiedEntityGroups = httpServletRequest.getHeader(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS);
+
                     final X509AuthenticationRequestToken x509Request = new X509AuthenticationRequestToken(
-                            httpServletRequest.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN), principalExtractor, certificates, httpServletRequest.getRemoteAddr());
+                            proxiedEntitiesChain, proxiedEntityGroups, principalExtractor, certificates, httpServletRequest.getRemoteAddr());
 
                     final NiFiAuthenticationToken authenticationResponse = (NiFiAuthenticationToken) x509AuthenticationProvider.authenticate(x509Request);
                     final NiFiUser nifiUser = ((NiFiUserDetails) authenticationResponse.getDetails()).getNiFiUser();
@@ -832,7 +1350,9 @@ public class AccessResource extends ApplicationResource {
 
         // If Kerberos Service Principal and keytab location not configured, throws exception
         if (!properties.isKerberosSpnegoSupportEnabled() || kerberosService == null) {
-            throw new IllegalStateException("Kerberos ticket login not supported by this NiFi.");
+            final String message = "Kerberos ticket login not supported by this NiFi.";
+            logger.warn(message);
+            return Response.status(Response.Status.CONFLICT).entity(message).build();
         }
 
         String authorizationHeaderValue = httpServletRequest.getHeader(KerberosService.AUTHORIZATION_HEADER_NAME);
@@ -962,21 +1482,82 @@ public class AccessResource extends ApplicationResource {
             throw new IllegalStateException(AUTHENTICATION_NOT_ENABLED_MSG);
         }
 
-        String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
+        final String mappedUserIdentity = NiFiUserUtils.getNiFiUserIdentity();
+        if (StringUtils.isBlank(mappedUserIdentity)) {
+            return Response.status(Response.Status.UNAUTHORIZED)
+                    .entity("Authentication token provided was empty or not in the correct JWT format.").build();
+        }
 
-        if (userIdentity != null && !userIdentity.isEmpty()) {
-            try {
-                logger.info("Logging out user " + userIdentity);
-                jwtService.logOutUsingAuthHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
-                logger.info("Successfully logged out user " + userIdentity);
-                return generateOkResponse().build();
-            } catch (final JwtException e) {
-                logger.error("Logout of user " + userIdentity + " failed due to: " + e.getMessage());
-                return Response.serverError().build();
+        try {
+            logger.info("Logging out " + mappedUserIdentity);
+            jwtService.logOutUsingAuthHeader(httpServletRequest.getHeader(JwtAuthenticationFilter.AUTHORIZATION));
+            logger.info("Successfully invalidated JWT for " + mappedUserIdentity);
+
+            // create a LogoutRequest and tell the LogoutRequestManager about it for later retrieval
+            final LogoutRequest logoutRequest = new LogoutRequest(UUID.randomUUID().toString(), mappedUserIdentity);
+            logoutRequestManager.start(logoutRequest);
+
+            // generate a cookie to store the logout request identifier
+            final Cookie cookie = new Cookie(LOGOUT_REQUEST_IDENTIFIER, logoutRequest.getRequestIdentifier());
+            cookie.setPath("/");
+            cookie.setHttpOnly(true);
+            cookie.setMaxAge(60);
+            cookie.setSecure(true);
+            httpServletResponse.addCookie(cookie);
+
+            return generateOkResponse().build();
+        } catch (final JwtException e) {
+            logger.error("Logout of user " + mappedUserIdentity + " failed due to: " + e.getMessage(), e);
+            return Response.serverError().build();
+        }
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.WILDCARD)
+    @Path("/logout/complete")
+    @ApiOperation(
+            value = "Completes the logout sequence by removing the cached Logout Request and Cookie if they existed and redirects to /nifi/login.",
+            notes = NON_GUARANTEED_ENDPOINT
+    )
+    @ApiResponses(
+            value = {
+                    @ApiResponse(code = 200, message = "User was logged out successfully."),
+                    @ApiResponse(code = 401, message = "Authentication token provided was empty or not in the correct JWT format."),
+                    @ApiResponse(code = 500, message = "Client failed to log out."),
             }
+    )
+    public void logOutComplete(@Context HttpServletRequest httpServletRequest, @Context HttpServletResponse httpServletResponse) throws Exception {
+        if (!httpServletRequest.isSecure()) {
+            throw new IllegalStateException("User authentication/authorization is only supported when running over HTTPS.");
+        }
+
+        // complete the logout request by removing the cookie and cached request, if they were present
+        completeLogoutRequest(httpServletResponse);
+
+        // redirect to logout landing page
+        httpServletResponse.sendRedirect(getNiFiLogoutCompleteUri());
+    }
+
+    private LogoutRequest completeLogoutRequest(final HttpServletResponse httpServletResponse) {
+        LogoutRequest logoutRequest = null;
+
+        // check if a logout request identifier is present and if so complete the request
+        final String logoutRequestIdentifier = getCookieValue(httpServletRequest.getCookies(), LOGOUT_REQUEST_IDENTIFIER);
+        if (logoutRequestIdentifier != null) {
+            logoutRequest = logoutRequestManager.complete(logoutRequestIdentifier);
+        }
+
+        if (logoutRequest == null) {
+            logger.warn("Logout request did not exist for identifier: " + logoutRequestIdentifier);
         } else {
-            return Response.status(401, "Authentication token provided was empty or not in the correct JWT format.").build();
+            logger.info("Completed logout request for " + logoutRequest.getMappedUserIdentity());
         }
+
+        // remove the cookie if it existed
+        removeLogoutRequestCookie(httpServletResponse);
+
+        return logoutRequest;
     }
 
     private long validateTokenExpiration(long proposedTokenExpiration, String identity) {
@@ -1033,8 +1614,24 @@ public class AccessResource extends ApplicationResource {
         return baseUrl + "/nifi";
     }
 
+    private String getNiFiLogoutCompleteUri() {
+        return getNiFiUri() + "/logout-complete";
+    }
+
     private void removeOidcRequestCookie(final HttpServletResponse httpServletResponse) {
-        final Cookie cookie = new Cookie(OIDC_REQUEST_IDENTIFIER, null);
+        removeCookie(httpServletResponse, OIDC_REQUEST_IDENTIFIER);
+    }
+
+    private void removeSamlRequestCookie(final HttpServletResponse httpServletResponse) {
+        removeCookie(httpServletResponse, SAML_REQUEST_IDENTIFIER);
+    }
+
+    private void removeLogoutRequestCookie(final HttpServletResponse httpServletResponse) {
+        removeCookie(httpServletResponse, LOGOUT_REQUEST_IDENTIFIER);
+    }
+
+    private void removeCookie(final HttpServletResponse httpServletResponse, final String cookieName) {
+        final Cookie cookie = new Cookie(cookieName, null);
         cookie.setPath("/");
         cookie.setHttpOnly(true);
         cookie.setMaxAge(0);
@@ -1042,8 +1639,17 @@ public class AccessResource extends ApplicationResource {
         httpServletResponse.addCookie(cookie);
     }
 
-    private void forwardToMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
-        httpServletRequest.setAttribute("title", OIDC_ERROR_TITLE);
+    private void forwardToLoginMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
+        forwardToMessagePage(httpServletRequest, httpServletResponse, LOGIN_ERROR_TITLE, message);
+    }
+
+    private void forwardToLogoutMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse, final String message) throws Exception {
+        forwardToMessagePage(httpServletRequest, httpServletResponse, LOGOUT_ERROR_TITLE, message);
+    }
+
+    private void forwardToMessagePage(final HttpServletRequest httpServletRequest, final HttpServletResponse httpServletResponse,
+                                      final String title, final String message) throws Exception {
+        httpServletRequest.setAttribute("title", title);
         httpServletRequest.setAttribute("messages", message);
 
         final ServletContext uiContext = httpServletRequest.getServletContext().getContext("/nifi");
@@ -1183,4 +1789,25 @@ public class AccessResource extends ApplicationResource {
     public void setKnoxService(KnoxService knoxService) {
         this.knoxService = knoxService;
     }
+
+    public void setSamlService(SAMLService samlService) {
+        this.samlService = samlService;
+    }
+
+    public void setSamlStateManager(SAMLStateManager samlStateManager) {
+        this.samlStateManager = samlStateManager;
+    }
+
+    public void setSamlCredentialStore(SAMLCredentialStore samlCredentialStore) {
+        this.samlCredentialStore = samlCredentialStore;
+    }
+
+    public void setIdpUserGroupService(IdpUserGroupService idpUserGroupService) {
+        this.idpUserGroupService = idpUserGroupService;
+    }
+
+    public void setLogoutRequestManager(LogoutRequestManager logoutRequestManager) {
+        this.logoutRequestManager = logoutRequestManager;
+    }
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardPolicyBasedAuthorizerDAO.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardPolicyBasedAuthorizerDAO.java
index 8173a9b..1d65214 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardPolicyBasedAuthorizerDAO.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardPolicyBasedAuthorizerDAO.java
@@ -102,6 +102,11 @@ public class StandardPolicyBasedAuthorizerDAO implements AccessPolicyDAO, UserGr
                         }
 
                         @Override
+                        public Group getGroupByName(String name) throws AuthorizationAccessException {
+                            throw new IllegalStateException(MSG_NON_MANAGED_AUTHORIZER);
+                        }
+
+                        @Override
                         public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
                             throw new IllegalStateException(MSG_NON_MANAGED_AUTHORIZER);
                         }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml
index 162560e..49e373a 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/resources/nifi-web-api-context.xml
@@ -578,6 +578,9 @@
         <property name="loginIdentityProvider" ref="loginIdentityProvider"/>
         <property name="oidcService" ref="oidcService"/>
         <property name="knoxService" ref="knoxService"/>
+        <property name="samlService" ref="samlService" />
+        <property name="samlStateManager" ref="samlStateManager"/>
+        <property name="samlCredentialStore" ref="samlCredentialStore"/>
         <property name="x509AuthenticationProvider" ref="x509AuthenticationProvider"/>
         <property name="certificateExtractor" ref="certificateExtractor"/>
         <property name="principalExtractor" ref="principalExtractor"/>
@@ -589,6 +592,8 @@
         <property name="clusterCoordinator" ref="clusterCoordinator"/>
         <property name="requestReplicator" ref="requestReplicator" />
         <property name="flowController" ref="flowController" />
+        <property name="idpUserGroupService" ref="idpUserGroupService" />
+        <property name="logoutRequestManager" ref="logoutRequestManager" />
     </bean>
     <bean id="accessPolicyResource" class="org.apache.nifi.web.api.AccessPolicyResource" scope="singleton">
         <constructor-arg ref="serviceFacade"/>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml
index 9cdbe31..547038a 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/pom.xml
@@ -65,6 +65,15 @@
                     <excludes>**/authentication/generated/*.java,</excludes>
                 </configuration>
             </plugin>
+            <plugin>
+                <groupId>org.apache.rat</groupId>
+                <artifactId>apache-rat-plugin</artifactId>
+                <configuration>
+                    <excludes combine.children="append">
+                        <exclude>src/test/resources/saml/sso-circle-meta.xml</exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
         </plugins>
     </build>
     <profiles>
@@ -198,6 +207,10 @@
             <scope>provided</scope>
         </dependency>
         <dependency>
+            <groupId>org.springframework.security.extensions</groupId>
+            <artifactId>spring-security-saml2-core</artifactId>
+        </dependency>
+        <dependency>
             <groupId>org.springframework.security.kerberos</groupId>
             <artifactId>spring-security-kerberos-core</artifactId>
         </dependency>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/ProxiedEntitiesUtils.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/ProxiedEntitiesUtils.java
index c630421..cff042b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/ProxiedEntitiesUtils.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/ProxiedEntitiesUtils.java
@@ -20,7 +20,9 @@ import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import java.util.stream.Collectors;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -38,6 +40,9 @@ public class ProxiedEntitiesUtils {
     public static final String PROXY_ENTITIES_ACCEPTED = "X-ProxiedEntitiesAccepted";
     public static final String PROXY_ENTITIES_DETAILS = "X-ProxiedEntitiesDetails";
 
+    public static final String PROXY_ENTITY_GROUPS = "X-ProxiedEntityGroups";
+    public static final String PROXY_ENTITY_GROUPS_EMPTY = "<>";
+
     private static final String GT = ">";
     private static final String ESCAPED_GT = "\\\\>";
     private static final String LT = "<";
@@ -103,6 +108,21 @@ public class ProxiedEntitiesUtils {
     }
 
     /**
+     * Tokenizes the specified proxied entity groups which are formatted the same as a proxy chain.
+     *
+     * @param rawProxyEntityGroups the raw proxy entity groups
+     * @return the set of group names, or empty set if none exist
+     */
+    public static Set<String> tokenizeProxiedEntityGroups(String rawProxyEntityGroups) {
+        final List<String> elements = tokenizeProxiedEntitiesChain(rawProxyEntityGroups);
+        if (elements.isEmpty()) {
+            return Collections.emptySet();
+        } else {
+            return elements.stream().filter(e -> !StringUtils.isBlank(e)).collect(Collectors.toSet());
+        }
+    }
+
+    /**
      * Builds the proxy chain for the specified user.
      *
      * @param user The current user
@@ -119,6 +139,27 @@ public class ProxiedEntitiesUtils {
     }
 
     /**
+     * Builds the string representation for a set of groups that belong to a proxied entity.
+     *
+     * The resulting string will be formatted similar to a proxied-entity chain.
+     *
+     * Example:
+     *   Groups set:    ("group1", "group2", "group3")
+     *   Returns:       {@code "<group1><group2><group3> }
+     *
+     * @param groups the set of groups
+     * @return the formatted group string, or null if there are no groups to proxy
+     */
+    public static String buildProxiedEntityGroupsString(final Set<String> groups) {
+        if (groups == null || groups.isEmpty()) {
+            return PROXY_ENTITY_GROUPS_EMPTY;
+        }
+
+        final List<String> formattedGroups = groups.stream().map(ProxiedEntitiesUtils::formatProxyDn).collect(Collectors.toList());
+        return StringUtils.join(formattedGroups, "");
+    }
+
+    /**
      * If a successfully authenticated request was made via a proxy, relevant proxy headers will be added to the response.
      *
      * @param request The proxied client request that was successfully authenticated.
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProvider.java
index 075720d..135472d 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProvider.java
@@ -17,6 +17,7 @@
 package org.apache.nifi.web.security.jwt;
 
 import io.jsonwebtoken.JwtException;
+import org.apache.nifi.admin.service.IdpUserGroupService;
 import org.apache.nifi.authorization.Authorizer;
 import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
@@ -28,16 +29,21 @@ import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 
+import java.util.Set;
+import java.util.stream.Collectors;
+
 /**
  *
  */
 public class JwtAuthenticationProvider extends NiFiAuthenticationProvider {
 
     private final JwtService jwtService;
+    private final IdpUserGroupService idpUserGroupService;
 
-    public JwtAuthenticationProvider(JwtService jwtService, NiFiProperties nifiProperties, Authorizer authorizer) {
+    public JwtAuthenticationProvider(JwtService jwtService, NiFiProperties nifiProperties, Authorizer authorizer, IdpUserGroupService idpUserGroupService) {
         super(nifiProperties, authorizer);
         this.jwtService = jwtService;
+        this.idpUserGroupService = idpUserGroupService;
     }
 
     @Override
@@ -47,7 +53,16 @@ public class JwtAuthenticationProvider extends NiFiAuthenticationProvider {
         try {
             final String jwtPrincipal = jwtService.getAuthenticationFromToken(request.getToken());
             final String mappedIdentity = mapIdentity(jwtPrincipal);
-            final NiFiUser user = new Builder().identity(mappedIdentity).groups(getUserGroups(mappedIdentity)).clientAddress(request.getClientAddress()).build();
+            final Set<String> userGroupProviderGroups = getUserGroups(mappedIdentity);
+            final Set<String> idpUserGroups = getIdpUserGroups(mappedIdentity);
+
+            final NiFiUser user = new Builder()
+                    .identity(mappedIdentity)
+                    .groups(userGroupProviderGroups)
+                    .identityProviderGroups(idpUserGroups)
+                    .clientAddress(request.getClientAddress())
+                    .build();
+
             return new NiFiAuthenticationToken(new NiFiUserDetails(user));
         } catch (JwtException e) {
             throw new InvalidAuthenticationException(e.getMessage(), e);
@@ -58,4 +73,11 @@ public class JwtAuthenticationProvider extends NiFiAuthenticationProvider {
     public boolean supports(Class<?> authentication) {
         return JwtAuthenticationRequestToken.class.isAssignableFrom(authentication);
     }
+
+    private Set<String> getIdpUserGroups(final String mappedIdentity) {
+        return idpUserGroupService.getUserGroups(mappedIdentity).stream()
+                .map(ug -> ug.getGroupName())
+                .collect(Collectors.toSet());
+    }
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
index 9569631..886ae46 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/jwt/JwtService.java
@@ -27,8 +27,6 @@ import io.jsonwebtoken.SignatureAlgorithm;
 import io.jsonwebtoken.SignatureException;
 import io.jsonwebtoken.SigningKeyResolverAdapter;
 import io.jsonwebtoken.UnsupportedJwtException;
-import java.nio.charset.StandardCharsets;
-import java.util.Calendar;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.admin.service.AdministrationException;
 import org.apache.nifi.admin.service.KeyService;
@@ -36,6 +34,11 @@ import org.apache.nifi.key.Key;
 import org.apache.nifi.web.security.token.LoginAuthenticationToken;
 import org.slf4j.LoggerFactory;
 
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Calendar;
+
 /**
  *
  */
@@ -144,6 +147,7 @@ public class JwtService {
         // Create a JWT with the specified authentication
         final String identity = principal.toString();
         final String username = authenticationToken.getName();
+        final String rawIssuer = authenticationToken.getIssuer();
 
         try {
             // Get/create the key for this user
@@ -152,11 +156,13 @@ public class JwtService {
 
             logger.trace("Generating JWT for " + authenticationToken);
 
+            final String encodedIssuer = URLEncoder.encode(rawIssuer, "UTF-8");
+
             // TODO: Implement "jti" claim with nonce to prevent replay attacks and allow blacklisting of revoked tokens
             // Build the token
             return Jwts.builder().setSubject(identity)
-                    .setIssuer(authenticationToken.getIssuer())
-                    .setAudience(authenticationToken.getIssuer())
+                    .setIssuer(encodedIssuer)
+                    .setAudience(encodedIssuer)
                     .claim(USERNAME_CLAIM, username)
                     .claim(KEY_ID_CLAIM, key.getId())
                     .setExpiration(expiration.getTime())
@@ -166,6 +172,10 @@ public class JwtService {
             final String errorMessage = "Could not retrieve the signing key for JWT for " + identity;
             logger.error(errorMessage, e);
             throw new JwtException(errorMessage, e);
+        } catch (UnsupportedEncodingException e) {
+            final String errorMessage = "Could not URL encode issuer: " + rawIssuer;
+            logger.error(errorMessage, e);
+            throw new JwtException(errorMessage, e);
         }
     }
 
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/knox/KnoxServiceFactoryBean.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/knox/KnoxServiceFactoryBean.java
index 2a83105..7a3aa07 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/knox/KnoxServiceFactoryBean.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/knox/KnoxServiceFactoryBean.java
@@ -28,8 +28,8 @@ public class KnoxServiceFactoryBean implements FactoryBean<KnoxService> {
     public KnoxService getObject() throws Exception {
         if (knoxService == null) {
             // ensure we only allow knox if login and oidc are disabled
-            if (properties.isKnoxSsoEnabled() && (properties.isLoginIdentityProviderEnabled() || properties.isOidcEnabled())) {
-                throw new RuntimeException("Apache Knox SSO support cannot be enabled if the Login Identity Provider or OpenId Connect is configured.");
+            if (properties.isKnoxSsoEnabled() && (properties.isLoginIdentityProviderEnabled() || properties.isOidcEnabled() || properties.isSamlEnabled())) {
+                throw new RuntimeException("Apache Knox SSO support cannot be enabled if the Login Identity Provider or OpenId Connect or SAML is configured.");
             }
 
             final KnoxConfiguration configuration = new StandardKnoxConfiguration(properties);
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequest.java
new file mode 100644
index 0000000..ac5c83b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequest.java
@@ -0,0 +1,61 @@
+/*
+ * 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.logout;
+
+import org.apache.commons.lang3.Validate;
+
+import java.util.Objects;
+
+public class LogoutRequest {
+
+    private final String requestIdentifier;
+
+    private final String mappedUserIdentity;
+
+    public LogoutRequest(final String requestIdentifier, final String mappedUserIdentity) {
+        this.requestIdentifier = Validate.notBlank(requestIdentifier, "Request identifier is required");
+        this.mappedUserIdentity = Validate.notBlank(mappedUserIdentity, "User identity is required");
+    }
+
+    public String getRequestIdentifier() {
+        return requestIdentifier;
+    }
+
+    public String getMappedUserIdentity() {
+        return mappedUserIdentity;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.requestIdentifier, this.mappedUserIdentity);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+
+        if (!(obj instanceof LogoutRequest)) {
+            return false;
+        }
+
+        final LogoutRequest other = (LogoutRequest) obj;
+        return Objects.equals(this.requestIdentifier, other.requestIdentifier)
+                && Objects.equals(this.mappedUserIdentity, other.mappedUserIdentity);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequestManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequestManager.java
new file mode 100644
index 0000000..395f46b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/logout/LogoutRequestManager.java
@@ -0,0 +1,85 @@
+/*
+ * 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.logout;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.web.security.util.CacheKey;
+
+import java.util.concurrent.TimeUnit;
+
+public class LogoutRequestManager {
+
+    // identifier from cookie -> logout request
+    private final Cache<CacheKey, LogoutRequest> requestLookup;
+
+    public LogoutRequestManager() {
+        this(60, TimeUnit.SECONDS);
+    }
+
+    public LogoutRequestManager(final int cacheExpiration, final TimeUnit units) {
+        this.requestLookup = CacheBuilder.newBuilder().expireAfterWrite(cacheExpiration, units).build();
+    }
+
+    public void start(final LogoutRequest logoutRequest) {
+        if (logoutRequest == null) {
+            throw new IllegalArgumentException("Logout Request is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(logoutRequest.getRequestIdentifier());
+
+        synchronized (requestLookup) {
+            final LogoutRequest existingRequest = requestLookup.getIfPresent(requestIdentifierKey);
+            if (existingRequest == null) {
+                requestLookup.put(requestIdentifierKey, logoutRequest);
+            } else {
+                throw new IllegalStateException("An existing logout request is already in progress");
+            }
+        }
+    }
+
+    public LogoutRequest get(final String requestIdentifier) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalArgumentException("Request identifier is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+
+        synchronized (requestLookup) {
+            final LogoutRequest logoutRequest = requestLookup.getIfPresent(requestIdentifierKey);
+            return logoutRequest;
+        }
+    }
+
+    public LogoutRequest complete(final String requestIdentifier) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalArgumentException("Request identifier is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+
+        synchronized (requestLookup) {
+            final LogoutRequest logoutRequest = requestLookup.getIfPresent(requestIdentifierKey);
+            if (logoutRequest != null) {
+                requestLookup.invalidate(requestIdentifierKey);
+            }
+            return logoutRequest;
+        }
+    }
+
+}
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 5474c43..5502ab5 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,16 +21,14 @@ 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.web.security.token.LoginAuthenticationToken;
+import org.apache.nifi.web.security.util.CacheKey;
+import org.apache.nifi.web.security.util.IdentityProviderUtils;
+
 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;
-import org.apache.nifi.web.security.token.LoginAuthenticationToken;
-import org.apache.nifi.web.security.util.CacheKey;
 
 import static org.apache.nifi.web.security.oidc.StandardOidcIdentityProvider.OPEN_ID_CONNECT_SUPPORT_IS_NOT_CONFIGURED;
 
@@ -138,12 +136,12 @@ public class OidcService {
         }
 
         final CacheKey oidcRequestIdentifierKey = new CacheKey(oidcRequestIdentifier);
-        final State state = new State(generateStateValue());
+        final State state = new State(IdentityProviderUtils.generateStateValue());
 
         try {
             synchronized (stateLookupForPendingRequests) {
                 final State cachedState = stateLookupForPendingRequests.get(oidcRequestIdentifierKey, () -> state);
-                if (!timeConstantEqualityCheck(state.getValue(), cachedState.getValue())) {
+                if (!IdentityProviderUtils.timeConstantEqualityCheck(state.getValue(), cachedState.getValue())) {
                     throw new IllegalStateException("An existing login request is already in progress.");
                 }
             }
@@ -155,18 +153,6 @@ public class OidcService {
     }
 
     /**
-     * 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.
      *
@@ -191,7 +177,7 @@ public class OidcService {
                 stateLookupForPendingRequests.invalidate(oidcRequestIdentifierKey);
             }
 
-            return state != null && timeConstantEqualityCheck(state.getValue(), proposedState.getValue());
+            return state != null && IdentityProviderUtils.timeConstantEqualityCheck(state.getValue(), proposedState.getValue());
         }
     }
 
@@ -255,7 +241,7 @@ public class OidcService {
             // Cache the jwt for later retrieval
             synchronized (jwtLookupForCompletedRequests) {
                 final String cachedJwt = jwtLookupForCompletedRequests.get(oidcRequestIdentifierKey, () -> jwt);
-                if (!timeConstantEqualityCheck(jwt, cachedJwt)) {
+                if (!IdentityProviderUtils.timeConstantEqualityCheck(jwt, cachedJwt)) {
                     throw new IllegalStateException("An existing login request is already in progress.");
                 }
             }
@@ -289,18 +275,4 @@ public class OidcService {
         }
     }
 
-    /**
-     * 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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/otp/OtpAuthenticationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/otp/OtpAuthenticationProvider.java
index bcc42cd..0b89b4b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/otp/OtpAuthenticationProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/otp/OtpAuthenticationProvider.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.web.security.otp;
 
+import org.apache.nifi.admin.service.IdpUserGroupService;
 import org.apache.nifi.authorization.Authorizer;
 import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
@@ -27,16 +28,21 @@ import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 
+import java.util.Set;
+import java.util.stream.Collectors;
+
 /**
  * This provider will be used when the request is attempting to authenticate with a download or ui extension OTP/token.
  */
 public class OtpAuthenticationProvider extends NiFiAuthenticationProvider {
 
     private OtpService otpService;
+    private final IdpUserGroupService idpUserGroupService;
 
-    public OtpAuthenticationProvider(OtpService otpService, NiFiProperties nifiProperties, Authorizer authorizer) {
+    public OtpAuthenticationProvider(OtpService otpService, NiFiProperties nifiProperties, Authorizer authorizer, IdpUserGroupService idpUserGroupService) {
         super(nifiProperties, authorizer);
         this.otpService = otpService;
+        this.idpUserGroupService = idpUserGroupService;
     }
 
     @Override
@@ -51,7 +57,16 @@ public class OtpAuthenticationProvider extends NiFiAuthenticationProvider {
                 otpPrincipal = otpService.getAuthenticationFromUiExtensionToken(request.getToken());
             }
             final String mappedIdentity = mapIdentity(otpPrincipal);
-            final NiFiUser user = new Builder().identity(mappedIdentity).groups(getUserGroups(mappedIdentity)).clientAddress(request.getClientAddress()).build();
+            final Set<String> userGroupProviderGroups = getUserGroups(mappedIdentity);
+            final Set<String> idpUserGroups = getIdpUserGroups(mappedIdentity);
+
+            final NiFiUser user = new Builder()
+                    .identity(mappedIdentity)
+                    .groups(userGroupProviderGroups)
+                    .identityProviderGroups(idpUserGroups)
+                    .clientAddress(request.getClientAddress())
+                    .build();
+
             return new NiFiAuthenticationToken(new NiFiUserDetails(user));
         } catch (OtpAuthenticationException e) {
             throw new InvalidAuthenticationException(e.getMessage(), e);
@@ -62,4 +77,11 @@ public class OtpAuthenticationProvider extends NiFiAuthenticationProvider {
     public boolean supports(Class<?> authentication) {
         return OtpAuthenticationRequestToken.class.isAssignableFrom(authentication);
     }
+
+    private Set<String> getIdpUserGroups(final String mappedIdentity) {
+        return idpUserGroupService.getUserGroups(mappedIdentity).stream()
+                .map(ug -> ug.getGroupName())
+                .collect(Collectors.toSet());
+    }
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/NiFiSAMLContextProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/NiFiSAMLContextProvider.java
new file mode 100644
index 0000000..98f0e5c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/NiFiSAMLContextProvider.java
@@ -0,0 +1,57 @@
+/*
+ * 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.saml;
+
+import org.opensaml.saml2.metadata.provider.MetadataProviderException;
+import org.springframework.security.saml.context.SAMLMessageContext;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Map;
+
+/**
+ * Specialized interface to add functionality to {@link org.springframework.security.saml.context.SAMLContextProvider}
+ */
+public interface NiFiSAMLContextProvider {
+
+    /**
+     * Creates a SAMLContext with local entity values filled. Also request and response must be stored in the context
+     * as message transports. Local entity ID is populated from data in the request object.
+     *
+     * @param request request
+     * @param response response
+     * @param parameters additional parameters
+     * @return context
+     * @throws MetadataProviderException in case of metadata problems
+     */
+    SAMLMessageContext getLocalEntity(HttpServletRequest request, HttpServletResponse response, Map<String,String> parameters)
+            throws MetadataProviderException;
+
+    /**
+     * Creates a SAMLContext with local entity and peer values filled. Also request and response must be stored in the context
+     * as message transports. Local and peer entity IDs are populated from data in the request object.
+     *
+     * @param request request
+     * @param response response
+     * @param parameters additional parameters
+     * @return context
+     * @throws MetadataProviderException in case of metadata problems
+     */
+    SAMLMessageContext getLocalAndPeerEntity(HttpServletRequest request, HttpServletResponse response, Map<String,String> parameters)
+            throws MetadataProviderException;
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfiguration.java
new file mode 100644
index 0000000..bf1db5a
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfiguration.java
@@ -0,0 +1,73 @@
+/*
+ * 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.saml;
+
+import org.springframework.security.saml.key.KeyManager;
+import org.springframework.security.saml.log.SAMLLogger;
+import org.springframework.security.saml.metadata.ExtendedMetadata;
+import org.springframework.security.saml.metadata.MetadataManager;
+import org.springframework.security.saml.processor.SAMLProcessor;
+import org.springframework.security.saml.websso.SingleLogoutProfile;
+import org.springframework.security.saml.websso.WebSSOProfile;
+import org.springframework.security.saml.websso.WebSSOProfileConsumer;
+import org.springframework.security.saml.websso.WebSSOProfileOptions;
+
+import java.util.Timer;
+
+public interface SAMLConfiguration {
+
+    String getSpEntityId();
+
+    SAMLProcessor getProcessor();
+
+    NiFiSAMLContextProvider getContextProvider();
+
+    SAMLLogger getLogger();
+
+    WebSSOProfileOptions getWebSSOProfileOptions();
+
+    WebSSOProfile getWebSSOProfile();
+
+    WebSSOProfile getWebSSOProfileECP();
+
+    WebSSOProfile getWebSSOProfileHoK();
+
+    WebSSOProfileConsumer getWebSSOProfileConsumer();
+
+    WebSSOProfileConsumer getWebSSOProfileHoKConsumer();
+
+    SingleLogoutProfile getSingleLogoutProfile();
+
+    ExtendedMetadata getExtendedMetadata();
+
+    MetadataManager getMetadataManager();
+
+    KeyManager getKeyManager();
+
+    Timer getBackgroundTaskTimer();
+
+    long getAuthExpiration();
+
+    String getIdentityAttributeName();
+
+    String getGroupAttributeName();
+
+    boolean isRequestSigningEnabled();
+
+    boolean isWantAssertionsSigned();
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfigurationFactory.java
similarity index 63%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfigurationFactory.java
index 3fcc6d8..9d5fcfe 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLConfigurationFactory.java
@@ -14,14 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao;
+package org.apache.nifi.web.security.saml;
 
-/**
- *
- */
-public interface DAOFactory {
+import org.apache.nifi.util.NiFiProperties;
+
+public interface SAMLConfigurationFactory {
 
-    ActionDAO getActionDAO();
+    /**
+     * Creates a SAMLConfiguration instance from the given NiFiProperties.
+     *
+     * @param properties the NiFiProperties instance
+     * @return the configuration instance
+     * @throws Exception if the configuration can't be created
+     */
+    SAMLConfiguration create(final NiFiProperties properties) throws Exception;
 
-    KeyDAO getKeyDAO();
 }
diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLCredentialStore.java
similarity index 52%
copy from nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLCredentialStore.java
index 6b8012b..07dada8 100644
--- a/nifi-framework-api/src/main/java/org/apache/nifi/authorization/user/NiFiUser.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLCredentialStore.java
@@ -14,39 +14,36 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.nifi.web.security.saml;
 
-package org.apache.nifi.authorization.user;
-
-import java.util.Set;
+import org.springframework.security.saml.SAMLCredential;
 
 /**
- * A representation of a NiFi user that has logged into the application
+ * Manages storage of SAML credentials.
  */
-public interface NiFiUser {
-
-    /**
-     * @return the unique identity of this user
-     */
-    String getIdentity();
-
-    /**
-     * @return the groups that this user belongs to if this nifi is configured to load user groups, null otherwise.
-     */
-    Set<String> getGroups();
+public interface SAMLCredentialStore {
 
     /**
-     * @return the next user in the proxied entities chain, or <code>null</code> if no more users exist in the chain.
+     * Saves the given SAML credential so it can later be retrieved by user identity.
+     *
+     * @param identity the identity of the user the credential belongs to
+     * @param credential the credential
      */
-    NiFiUser getChain();
+    void save(String identity, SAMLCredential credential);
 
     /**
-     * @return <code>true</code> if the user is the unauthenticated Anonymous user
+     * Retrieves the credential for the given user identity.
+     *
+     * @param identity the user identity
+     * @return the credential, or null if none exists
      */
-    boolean isAnonymous();
+    SAMLCredential get(String identity);
 
     /**
-     * @return the address of the client that made the request which created this user
+     * Deletes the credential for the given user identity.
+     *
+     * @param identity the user identity
      */
-    String getClientAddress();
+    void delete(String identity);
 
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLEndpoints.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLEndpoints.java
new file mode 100644
index 0000000..9ce860c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLEndpoints.java
@@ -0,0 +1,42 @@
+/*
+ * 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.saml;
+
+public interface SAMLEndpoints {
+
+    String SERVICE_PROVIDER_METADATA_RELATIVE = "/saml/metadata";
+    String SERVICE_PROVIDER_METADATA = "/access" + SERVICE_PROVIDER_METADATA_RELATIVE;
+
+    String LOGIN_REQUEST_RELATIVE = "/saml/login/request";
+    String LOGIN_REQUEST = "/access" + LOGIN_REQUEST_RELATIVE;
+
+    String LOGIN_CONSUMER_RELATIVE = "/saml/login/consumer";
+    String LOGIN_CONSUMER = "/access" + LOGIN_CONSUMER_RELATIVE;
+
+    String LOGIN_EXCHANGE_RELATIVE = "/saml/login/exchange";
+    String LOGIN_EXCHANGE = "/access" + LOGIN_EXCHANGE_RELATIVE;
+
+    String LOCAL_LOGOUT_RELATIVE = "/saml/local-logout";
+    String LOCAL_LOGOUT = "/access" + LOCAL_LOGOUT_RELATIVE;
+
+    String SINGLE_LOGOUT_REQUEST_RELATIVE = "/saml/single-logout/request";
+    String SINGLE_LOGOUT_REQUEST = "/access" + SINGLE_LOGOUT_REQUEST_RELATIVE;
+
+    String SINGLE_LOGOUT_CONSUMER_RELATIVE = "/saml/single-logout/consumer";
+    String SINGLE_LOGOUT_CONSUMER = "/access" + SINGLE_LOGOUT_CONSUMER_RELATIVE;
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLService.java
new file mode 100644
index 0000000..5fa7a6d
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLService.java
@@ -0,0 +1,128 @@
+/*
+ * 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.saml;
+
+import org.springframework.security.saml.SAMLCredential;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Map;
+import java.util.Set;
+
+public interface SAMLService {
+
+    String SAML_SUPPORT_IS_NOT_CONFIGURED = "SAML support is not configured";
+
+    /**
+     * Initializes the service.
+     */
+    void initialize();
+
+    /**
+     * @return whether SAML support is enabled
+     */
+    boolean isSamlEnabled();
+
+    /**
+     * @return true if the service provider metadata has been initialized, false otherwise
+     */
+    boolean isServiceProviderInitialized();
+
+    /**
+     * Initializes the service provider metadata.
+     *
+     * This method must be called before using the service to perform any other SAML operations.
+     *
+     * @param baseUrl the baseUrl of the service provider
+     */
+    void initializeServiceProvider(String baseUrl);
+
+    /**
+     * Retrieves the service provider metadata XML.
+     */
+    String getServiceProviderMetadata();
+
+    /**
+     * Retrieves the expiration time in milliseconds for a SAML authentication.
+     *
+     * @return the authentication
+     */
+    long getAuthExpiration();
+
+    /**
+     * Initiates a login sequence with the SAML identity provider.
+     *
+     * @param request servlet request
+     * @param response servlet response
+     */
+    void initiateLogin(HttpServletRequest request, HttpServletResponse response, String relayState);
+
+    /**
+     * Processes the assertions coming back from the identity provider and returns a NiFi JWT.
+     *
+     * @param request servlet request
+     * @param response servlet request
+     * @param parameters a map of parameters
+     * @return a NiFi JWT
+     */
+    SAMLCredential processLogin(HttpServletRequest request, HttpServletResponse response, Map<String,String> parameters);
+
+    /**
+     * Returns the identity of the user based on the given credential.
+     *
+     * If no identity attribute is specified in nifi.properties, then the NameID of the Subject will be used.
+     *
+     * Otherwise the value of the given identity attribute will be used.
+     *
+     * @param samlCredential the SAML credential returned from a successful authentication
+     * @return the user identity
+     */
+    String getUserIdentity(SAMLCredential samlCredential);
+
+    /**
+     * Returns the names of the groups the user belongs from looking at the assertions in the credential.
+     *
+     * Requires configuring the name of the group attribute in nifi.properties, otherwise an empty set will be returned.
+     *
+     * @param credential the SAML credential returned from a successful authentication
+     * @return the set of groups the user belongs to, or empty set if none exist or if nifi has not been configured with a group attribute name
+     */
+    Set<String> getUserGroups(SAMLCredential credential);
+
+    /**
+     * Initiates a logout sequence with the SAML identity provider.
+     *
+     * @param request servlet request
+     * @param response servlet response
+     */
+    void initiateLogout(HttpServletRequest request, HttpServletResponse response, SAMLCredential credential);
+
+    /**
+     * Processes a logout, typically a response from previously initiating a logout, but may be an IDP initiated logout.
+     *
+     * @param request servlet request
+     * @param response servlet response
+     * @param parameters a map of parameters
+     */
+    void processLogout(HttpServletRequest request, HttpServletResponse response, Map<String,String> parameters);
+
+    /**
+     * Shuts down the service.
+     */
+    void shutdown();
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLStateManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLStateManager.java
new file mode 100644
index 0000000..f3a358b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/SAMLStateManager.java
@@ -0,0 +1,61 @@
+/*
+ * 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.saml;
+
+import org.apache.nifi.web.security.token.LoginAuthenticationToken;
+
+/**
+ * Manages the state of active SAML requests.
+ */
+public interface SAMLStateManager {
+
+    /**
+     * Creates the initial state for starting a SAML login sequence.
+     *
+     * @param requestIdentifier a unique identifier for the current request/login-sequence
+     * @return a state value for the given request
+     */
+    String createState(String requestIdentifier);
+
+    /**
+     * Determines if the proposed state matches the stored state for the given request.
+     *
+     * @param requestIdentifier the request identifier
+     * @param proposedState the proposed state for the given request
+     * @return true if the proposed state matches the actual state
+     */
+    boolean isStateValid(String requestIdentifier, String proposedState);
+
+    /**
+     * Creates a NiFi JWT from the token and caches the JWT for future retrieval.
+     *
+     * @param requestIdentifier the request identifier
+     * @param token the login authentication token to create the JWT from
+     */
+    void createJwt(String requestIdentifier, LoginAuthenticationToken token);
+
+    /**
+     * Retrieves the JWT for the given request identifier that was created by previously calling {@method createJwt}.
+     *
+     * The JWT will be removed from the state cache upon retrieval.
+     *
+     * @param requestIdentifier the request identifier
+     * @return the NiFi JWT for the given request
+     */
+    String getJwt(String requestIdentifier);
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/NiFiSAMLContextProviderImpl.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/NiFiSAMLContextProviderImpl.java
new file mode 100644
index 0000000..b85f176
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/NiFiSAMLContextProviderImpl.java
@@ -0,0 +1,114 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.nifi.web.security.saml.NiFiSAMLContextProvider;
+import org.opensaml.saml2.metadata.provider.MetadataProviderException;
+import org.opensaml.ws.transport.http.HttpServletRequestAdapter;
+import org.opensaml.ws.transport.http.HttpServletResponseAdapter;
+import org.springframework.security.saml.context.SAMLContextProviderImpl;
+import org.springframework.security.saml.context.SAMLMessageContext;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Implementation of NiFiSAMLContextProvider that inherits from the standard SAMLContextProviderImpl.
+ */
+public class NiFiSAMLContextProviderImpl extends SAMLContextProviderImpl implements NiFiSAMLContextProvider {
+
+    @Override
+    public SAMLMessageContext getLocalEntity(HttpServletRequest request, HttpServletResponse response, Map<String, String> parameters)
+            throws MetadataProviderException {
+
+        SAMLMessageContext context = new SAMLMessageContext();
+        populateGenericContext(request, response, parameters, context);
+        populateLocalEntityId(context, request.getRequestURI());
+        populateLocalContext(context);
+        return context;
+    }
+
+    @Override
+    public SAMLMessageContext getLocalAndPeerEntity(HttpServletRequest request, HttpServletResponse response, Map<String, String> parameters)
+            throws MetadataProviderException {
+
+        SAMLMessageContext context = new SAMLMessageContext();
+        populateGenericContext(request, response, parameters, context);
+        populateLocalEntityId(context, request.getRequestURI());
+        populateLocalContext(context);
+        populatePeerEntityId(context);
+        populatePeerContext(context);
+        return context;
+    }
+
+    protected void populateGenericContext(HttpServletRequest request, HttpServletResponse response, Map<String, String> parameters, SAMLMessageContext context) {
+        HttpServletRequestAdapter inTransport = new HttpServletRequestWithParameters(request, parameters);
+        HttpServletResponseAdapter outTransport = new HttpServletResponseAdapter(response, request.isSecure());
+
+        // Store attribute which cannot be located from InTransport directly
+        request.setAttribute(org.springframework.security.saml.SAMLConstants.LOCAL_CONTEXT_PATH, request.getContextPath());
+
+        context.setMetadataProvider(metadata);
+        context.setInboundMessageTransport(inTransport);
+        context.setOutboundMessageTransport(outTransport);
+
+        context.setMessageStorage(storageFactory.getMessageStorage(request));
+    }
+
+    /**
+     * Extends the HttpServletRequestAdapter with a provided set of parameters.
+     */
+    private static class HttpServletRequestWithParameters extends HttpServletRequestAdapter {
+
+        private final Map<String, String> providedParameters;
+
+        public HttpServletRequestWithParameters(HttpServletRequest request, Map<String,String> providedParameters) {
+            super(request);
+            this.providedParameters = providedParameters == null ? Collections.emptyMap() : providedParameters;
+        }
+
+        @Override
+        public String getParameterValue(String name) {
+            String value = super.getParameterValue(name);
+            if (value == null) {
+                value = providedParameters.get(name);
+            }
+            return value;
+        }
+
+        @Override
+        public List<String> getParameterValues(String name) {
+            List<String> combinedValues = new ArrayList<>();
+
+            List<String> initialValues = super.getParameterValues(name);
+            if (initialValues != null) {
+                combinedValues.addAll(initialValues);
+            }
+
+            String providedValue = providedParameters.get(name);
+            if (providedValue != null) {
+                combinedValues.add(providedValue);
+            }
+
+            return combinedValues;
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfiguration.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfiguration.java
new file mode 100644
index 0000000..8bc4583
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfiguration.java
@@ -0,0 +1,328 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.nifi.web.security.saml.NiFiSAMLContextProvider;
+import org.apache.nifi.web.security.saml.SAMLConfiguration;
+import org.springframework.security.saml.key.KeyManager;
+import org.springframework.security.saml.log.SAMLLogger;
+import org.springframework.security.saml.metadata.ExtendedMetadata;
+import org.springframework.security.saml.metadata.MetadataManager;
+import org.springframework.security.saml.processor.SAMLProcessor;
+import org.springframework.security.saml.websso.SingleLogoutProfile;
+import org.springframework.security.saml.websso.WebSSOProfile;
+import org.springframework.security.saml.websso.WebSSOProfileConsumer;
+import org.springframework.security.saml.websso.WebSSOProfileOptions;
+
+import java.util.Objects;
+import java.util.Timer;
+
+public class StandardSAMLConfiguration implements SAMLConfiguration {
+
+    private final String spEntityId;
+
+    private final SAMLProcessor processor;
+    private final NiFiSAMLContextProvider contextProvider;
+    private final SAMLLogger logger;
+
+    private final WebSSOProfileOptions webSSOProfileOptions;
+
+    private final WebSSOProfile webSSOProfile;
+    private final WebSSOProfile webSSOProfileECP;
+    private final WebSSOProfile webSSOProfileHoK;
+
+    private final WebSSOProfileConsumer webSSOProfileConsumer;
+    private final WebSSOProfileConsumer webSSOProfileHoKConsumer;
+
+    private final SingleLogoutProfile singleLogoutProfile;
+
+    private final ExtendedMetadata extendedMetadata;
+    private final MetadataManager metadataManager;
+
+    private final KeyManager keyManager;
+    private final Timer backgroundTaskTimer;
+
+    private final long authExpiration;
+    private final String identityAttributeName;
+    private final String groupAttributeName;
+
+    private final boolean requestSigningEnabled;
+    private final boolean wantAssertionsSigned;
+
+    private StandardSAMLConfiguration(final Builder builder) {
+        this.spEntityId = Objects.requireNonNull(builder.spEntityId);
+        this.processor = Objects.requireNonNull(builder.processor);
+        this.contextProvider = Objects.requireNonNull(builder.contextProvider);
+        this.logger = Objects.requireNonNull(builder.logger);
+        this.webSSOProfileOptions = Objects.requireNonNull(builder.webSSOProfileOptions);
+        this.webSSOProfile = Objects.requireNonNull(builder.webSSOProfile);
+        this.webSSOProfileECP = Objects.requireNonNull(builder.webSSOProfileECP);
+        this.webSSOProfileHoK = Objects.requireNonNull(builder.webSSOProfileHoK);
+        this.webSSOProfileConsumer = Objects.requireNonNull(builder.webSSOProfileConsumer);
+        this.webSSOProfileHoKConsumer = Objects.requireNonNull(builder.webSSOProfileHoKConsumer);
+        this.singleLogoutProfile = Objects.requireNonNull(builder.singleLogoutProfile);
+        this.extendedMetadata = Objects.requireNonNull(builder.extendedMetadata);
+        this.metadataManager = Objects.requireNonNull(builder.metadataManager);
+        this.keyManager = Objects.requireNonNull(builder.keyManager);
+        this.backgroundTaskTimer = Objects.requireNonNull(builder.backgroundTaskTimer);
+        this.authExpiration = builder.authExpiration;
+        this.identityAttributeName = builder.identityAttributeName;
+        this.groupAttributeName = builder.groupAttributeName;
+        this.requestSigningEnabled = builder.requestSigningEnabled;
+        this.wantAssertionsSigned = builder.wantAssertionsSigned;
+    }
+
+    @Override
+    public String getSpEntityId() {
+        return spEntityId;
+    }
+
+    @Override
+    public SAMLProcessor getProcessor() {
+        return processor;
+    }
+
+    @Override
+    public NiFiSAMLContextProvider getContextProvider() {
+        return contextProvider;
+    }
+
+    @Override
+    public SAMLLogger getLogger() {
+        return logger;
+    }
+
+    @Override
+    public WebSSOProfileOptions getWebSSOProfileOptions() {
+        return webSSOProfileOptions;
+    }
+
+    @Override
+    public WebSSOProfile getWebSSOProfile() {
+        return webSSOProfile;
+    }
+
+    @Override
+    public WebSSOProfile getWebSSOProfileECP() {
+        return webSSOProfileECP;
+    }
+
+    @Override
+    public WebSSOProfile getWebSSOProfileHoK() {
+        return webSSOProfileHoK;
+    }
+
+    @Override
+    public WebSSOProfileConsumer getWebSSOProfileConsumer() {
+        return webSSOProfileConsumer;
+    }
+
+    @Override
+    public WebSSOProfileConsumer getWebSSOProfileHoKConsumer() {
+        return webSSOProfileHoKConsumer;
+    }
+
+    @Override
+    public SingleLogoutProfile getSingleLogoutProfile() {
+        return singleLogoutProfile;
+    }
+
+    @Override
+    public ExtendedMetadata getExtendedMetadata() {
+        return extendedMetadata;
+    }
+
+    @Override
+    public MetadataManager getMetadataManager() {
+        return metadataManager;
+    }
+
+    @Override
+    public KeyManager getKeyManager() {
+        return keyManager;
+    }
+
+    @Override
+    public Timer getBackgroundTaskTimer() {
+        return backgroundTaskTimer;
+    }
+
+    @Override
+    public long getAuthExpiration() {
+        return authExpiration;
+    }
+
+    @Override
+    public String getIdentityAttributeName() {
+        return identityAttributeName;
+    }
+
+    @Override
+    public String getGroupAttributeName() {
+        return groupAttributeName;
+    }
+
+    @Override
+    public boolean isRequestSigningEnabled() {
+        return requestSigningEnabled;
+    }
+
+    @Override
+    public boolean isWantAssertionsSigned() {
+        return wantAssertionsSigned;
+    }
+
+    /**
+     * Builder for SAMLConfiguration.
+     */
+    public static class Builder {
+
+        private String spEntityId;
+
+        private SAMLProcessor processor;
+        private NiFiSAMLContextProvider contextProvider;
+        private SAMLLogger logger;
+
+        private WebSSOProfileOptions webSSOProfileOptions;
+
+        private WebSSOProfile webSSOProfile;
+        private WebSSOProfile webSSOProfileECP;
+        private WebSSOProfile webSSOProfileHoK;
+
+        private WebSSOProfileConsumer webSSOProfileConsumer;
+        private WebSSOProfileConsumer webSSOProfileHoKConsumer;
+
+        private SingleLogoutProfile singleLogoutProfile;
+
+        private ExtendedMetadata extendedMetadata;
+        private MetadataManager metadataManager;
+
+        private KeyManager keyManager;
+        private Timer backgroundTaskTimer;
+
+        private long authExpiration;
+        private String groupAttributeName;
+        private String identityAttributeName;
+
+        private boolean requestSigningEnabled;
+        private boolean wantAssertionsSigned;
+
+        public Builder spEntityId(String spEntityId) {
+            this.spEntityId = spEntityId;
+            return this;
+        }
+
+        public Builder processor(SAMLProcessor processor) {
+            this.processor = processor;
+            return this;
+        }
+
+        public Builder contextProvider(NiFiSAMLContextProvider contextProvider) {
+            this.contextProvider = contextProvider;
+            return this;
+        }
+
+        public Builder logger(SAMLLogger logger) {
+            this.logger = logger;
+            return this;
+        }
+
+        public Builder webSSOProfileOptions(WebSSOProfileOptions webSSOProfileOptions) {
+            this.webSSOProfileOptions = webSSOProfileOptions;
+            return this;
+        }
+
+        public Builder webSSOProfile(WebSSOProfile webSSOProfile) {
+            this.webSSOProfile = webSSOProfile;
+            return this;
+        }
+
+        public Builder webSSOProfileECP(WebSSOProfile webSSOProfileECP) {
+            this.webSSOProfileECP = webSSOProfileECP;
+            return this;
+        }
+
+        public Builder webSSOProfileHoK(WebSSOProfile webSSOProfileHoK) {
+            this.webSSOProfileHoK = webSSOProfileHoK;
+            return this;
+        }
+
+        public Builder webSSOProfileConsumer(WebSSOProfileConsumer webSSOProfileConsumer) {
+            this.webSSOProfileConsumer = webSSOProfileConsumer;
+            return this;
+        }
+
+        public Builder webSSOProfileHoKConsumer(WebSSOProfileConsumer webSSOProfileHoKConsumer) {
+            this.webSSOProfileHoKConsumer = webSSOProfileHoKConsumer;
+            return this;
+        }
+
+        public Builder singleLogoutProfile(SingleLogoutProfile singleLogoutProfile) {
+            this.singleLogoutProfile = singleLogoutProfile;
+            return this;
+        }
+
+        public Builder extendedMetadata(ExtendedMetadata extendedMetadata) {
+            this.extendedMetadata = extendedMetadata;
+            return this;
+        }
+
+        public Builder metadataManager(MetadataManager metadataManager) {
+            this.metadataManager = metadataManager;
+            return this;
+        }
+
+        public Builder keyManager(KeyManager keyManager) {
+            this.keyManager = keyManager;
+            return this;
+        }
+
+        public Builder backgroundTaskTimer(Timer backgroundTaskTimer) {
+            this.backgroundTaskTimer = backgroundTaskTimer;
+            return this;
+        }
+
+        public Builder authExpiration(long authExpiration) {
+            this.authExpiration = authExpiration;
+            return this;
+        }
+
+        public Builder identityAttributeName(String identityAttributeName) {
+            this.identityAttributeName = identityAttributeName;
+            return this;
+        }
+
+        public Builder groupAttributeName(String groupAttributeName) {
+            this.groupAttributeName = groupAttributeName;
+            return this;
+        }
+
+        public Builder requestSigningEnabled(boolean requestSigningEnabled) {
+            this.requestSigningEnabled = requestSigningEnabled;
+            return this;
+        }
+
+        public Builder wantAssertionsSigned(boolean wantAssertionsSigned) {
+            this.wantAssertionsSigned = wantAssertionsSigned;
+            return this;
+        }
+
+        public SAMLConfiguration build() {
+            return new StandardSAMLConfiguration(this);
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfigurationFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfigurationFactory.java
new file mode 100644
index 0000000..e143524
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLConfigurationFactory.java
@@ -0,0 +1,502 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.commons.httpclient.HttpClient;
+import org.apache.commons.httpclient.params.HttpClientParams;
+import org.apache.commons.httpclient.params.HttpConnectionParams;
+import org.apache.commons.httpclient.protocol.Protocol;
+import org.apache.commons.httpclient.protocol.ProtocolSocketFactory;
+import org.apache.nifi.security.util.KeyStoreUtils;
+import org.apache.nifi.security.util.SslContextFactory;
+import org.apache.nifi.security.util.StandardTlsConfiguration;
+import org.apache.nifi.security.util.TlsConfiguration;
+import org.apache.nifi.security.util.TlsException;
+import org.apache.nifi.util.FormatUtils;
+import org.apache.nifi.util.NiFiProperties;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.web.security.saml.NiFiSAMLContextProvider;
+import org.apache.nifi.web.security.saml.SAMLConfiguration;
+import org.apache.nifi.web.security.saml.SAMLConfigurationFactory;
+import org.apache.nifi.web.security.saml.impl.tls.CompositeKeyManager;
+import org.apache.nifi.web.security.saml.impl.tls.CustomTLSProtocolSocketFactory;
+import org.apache.nifi.web.security.saml.impl.tls.TruststoreStrategy;
+import org.apache.velocity.app.VelocityEngine;
+import org.opensaml.Configuration;
+import org.opensaml.saml2.metadata.provider.FilesystemMetadataProvider;
+import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider;
+import org.opensaml.saml2.metadata.provider.MetadataProvider;
+import org.opensaml.saml2.metadata.provider.MetadataProviderException;
+import org.opensaml.xml.parse.ParserPool;
+import org.opensaml.xml.parse.StaticBasicParserPool;
+import org.opensaml.xml.parse.XMLParserException;
+import org.opensaml.xml.security.BasicSecurityConfiguration;
+import org.opensaml.xml.security.SecurityHelper;
+import org.opensaml.xml.security.credential.Credential;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.saml.SAMLBootstrap;
+import org.springframework.security.saml.key.JKSKeyManager;
+import org.springframework.security.saml.key.KeyManager;
+import org.springframework.security.saml.log.SAMLDefaultLogger;
+import org.springframework.security.saml.log.SAMLLogger;
+import org.springframework.security.saml.metadata.CachingMetadataManager;
+import org.springframework.security.saml.metadata.ExtendedMetadata;
+import org.springframework.security.saml.metadata.ExtendedMetadataDelegate;
+import org.springframework.security.saml.metadata.MetadataManager;
+import org.springframework.security.saml.processor.HTTPArtifactBinding;
+import org.springframework.security.saml.processor.HTTPPAOS11Binding;
+import org.springframework.security.saml.processor.HTTPPostBinding;
+import org.springframework.security.saml.processor.HTTPRedirectDeflateBinding;
+import org.springframework.security.saml.processor.HTTPSOAP11Binding;
+import org.springframework.security.saml.processor.SAMLBinding;
+import org.springframework.security.saml.processor.SAMLProcessor;
+import org.springframework.security.saml.processor.SAMLProcessorImpl;
+import org.springframework.security.saml.util.VelocityFactory;
+import org.springframework.security.saml.websso.ArtifactResolutionProfileImpl;
+import org.springframework.security.saml.websso.SingleLogoutProfile;
+import org.springframework.security.saml.websso.SingleLogoutProfileImpl;
+import org.springframework.security.saml.websso.WebSSOProfile;
+import org.springframework.security.saml.websso.WebSSOProfileConsumer;
+import org.springframework.security.saml.websso.WebSSOProfileConsumerHoKImpl;
+import org.springframework.security.saml.websso.WebSSOProfileConsumerImpl;
+import org.springframework.security.saml.websso.WebSSOProfileECPImpl;
+import org.springframework.security.saml.websso.WebSSOProfileHoKImpl;
+import org.springframework.security.saml.websso.WebSSOProfileImpl;
+import org.springframework.security.saml.websso.WebSSOProfileOptions;
+
+import javax.net.ssl.SSLSocketFactory;
+import javax.servlet.ServletException;
+import java.io.File;
+import java.net.URI;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.Timer;
+import java.util.concurrent.TimeUnit;
+
+public class StandardSAMLConfigurationFactory implements SAMLConfigurationFactory {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(StandardSAMLConfigurationFactory.class);
+
+    public SAMLConfiguration create(final NiFiProperties properties) throws Exception {
+        // ensure we only configure SAML when OIDC/KnoxSSO/LoginIdentityProvider are not enabled
+        if (properties.isOidcEnabled() || properties.isKnoxSsoEnabled() || properties.isLoginIdentityProviderEnabled()) {
+            throw new RuntimeException("SAML cannot be enabled if the Login Identity Provider or OpenId Connect or KnoxSSO is configured.");
+        }
+
+        LOGGER.info("Initializing SAML configuration...");
+
+        // Load and validate config from nifi.properties...
+
+        final String rawEntityId = properties.getSamlServiceProviderEntityId();
+        if (StringUtils.isBlank(rawEntityId)) {
+            throw new RuntimeException("Entity ID is required when configuring SAML");
+        }
+
+        final String spEntityId = rawEntityId;
+        LOGGER.info("Service Provider Entity ID = '{}'", spEntityId);
+
+        final String rawIdpMetadataUrl = properties.getSamlIdentityProviderMetadataUrl();
+
+        if (StringUtils.isBlank(rawIdpMetadataUrl)) {
+            throw new RuntimeException("IDP Metadata URL is required when configuring SAML");
+        }
+
+        if (!rawIdpMetadataUrl.startsWith("file://")
+                && !rawIdpMetadataUrl.startsWith("http://")
+                && !rawIdpMetadataUrl.startsWith("https://")) {
+            throw new RuntimeException("IDP Medata URL must start with file://, http://, or https://");
+        }
+
+        final URI idpMetadataLocation = URI.create(rawIdpMetadataUrl);
+        LOGGER.info("Identity Provider Metadata Location = '{}'", idpMetadataLocation);
+
+        final String authExpirationFromProperties = properties.getSamlAuthenticationExpiration();
+        LOGGER.info("Authentication Expiration = '{}'", authExpirationFromProperties);
+
+        final long authExpiration;
+        try {
+            authExpiration = Math.round(FormatUtils.getPreciseTimeDuration(authExpirationFromProperties, TimeUnit.MILLISECONDS));
+        } catch (IllegalArgumentException e) {
+            throw new RuntimeException("Invalid SAML authentication expiration: " + authExpirationFromProperties);
+        }
+
+        final String identityAttributeName = properties.getSamlIdentityAttributeName();
+        if (!StringUtils.isBlank(identityAttributeName)) {
+            LOGGER.info("Identity Attribute Name = '{}'", identityAttributeName);
+        }
+
+        final String groupAttributeName = properties.getSamlGroupAttributeName();
+        if (!StringUtils.isBlank(groupAttributeName)) {
+            LOGGER.info("Group Attribute Name = '{}'", groupAttributeName);
+        }
+
+        final TruststoreStrategy truststoreStrategy;
+        try {
+            truststoreStrategy = TruststoreStrategy.valueOf(properties.getSamlHttpClientTruststoreStrategy());
+            LOGGER.info("HttpClient Truststore Strategy = `{}`", truststoreStrategy.name());
+        } catch (Exception e) {
+            throw new RuntimeException("Truststore Strategy must be one of " + TruststoreStrategy.NIFI.name() + " or " + TruststoreStrategy.JDK.name());
+        }
+
+        int connectTimeout;
+        final String rawConnectTimeout = properties.getSamlHttpClientConnectTimeout();
+        try {
+            connectTimeout = (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_SAML_HTTP_CLIENT_CONNECT_TIMEOUT, rawConnectTimeout, NiFiProperties.DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT);
+            connectTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_CONNECT_TIMEOUT, TimeUnit.MILLISECONDS);
+        }
+
+        int readTimeout;
+        final String rawReadTimeout = properties.getSamlHttpClientReadTimeout();
+        try {
+            readTimeout = (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_SAML_HTTP_CLIENT_READ_TIMEOUT, rawReadTimeout, NiFiProperties.DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT);
+            readTimeout = (int) FormatUtils.getPreciseTimeDuration(NiFiProperties.DEFAULT_SECURITY_USER_SAML_HTTP_CLIENT_READ_TIMEOUT, TimeUnit.MILLISECONDS);
+        }
+
+        // Initialize spring-security-saml/OpenSAML objects...
+
+        final SAMLBootstrap samlBootstrap = new SAMLBootstrap();
+        samlBootstrap.postProcessBeanFactory(null);
+
+        final ParserPool parserPool = createParserPool();
+        final VelocityEngine velocityEngine = VelocityFactory.getEngine();
+
+        final TlsConfiguration tlsConfiguration = StandardTlsConfiguration.fromNiFiProperties(properties);
+        final KeyManager keyManager = createKeyManager(tlsConfiguration);
+
+        final HttpClient httpClient = createHttpClient(connectTimeout, readTimeout);
+        if (truststoreStrategy == TruststoreStrategy.NIFI) {
+            configureCustomTLSSocketFactory(tlsConfiguration);
+        }
+
+        final boolean signMetadata = properties.isSamlMetadataSigningEnabled();
+        final String signatureAlgorithm = properties.getSamlSignatureAlgorithm();
+        final String signatureDigestAlgorithm = properties.getSamlSignatureDigestAlgorithm();
+        configureGlobalSecurityDefaults(keyManager, signatureAlgorithm, signatureDigestAlgorithm);
+
+        final ExtendedMetadata extendedMetadata = createExtendedMetadata(signatureAlgorithm, signMetadata);
+
+        final Timer backgroundTaskTimer = new Timer(true);
+        final MetadataProvider idpMetadataProvider = createIdpMetadataProvider(idpMetadataLocation, httpClient, backgroundTaskTimer, parserPool);
+
+        final MetadataManager metadataManager = createMetadataManager(idpMetadataProvider, extendedMetadata, keyManager);
+
+        final SAMLProcessor processor = createSAMLProcessor(parserPool, velocityEngine, httpClient);
+        final NiFiSAMLContextProvider contextProvider = createContextProvider(metadataManager, keyManager);
+
+        // Build the configuration instance...
+
+        return new StandardSAMLConfiguration.Builder()
+                .spEntityId(spEntityId)
+                .processor(processor)
+                .contextProvider(contextProvider)
+                .logger(createSAMLLogger(properties))
+                .webSSOProfileOptions(createWebSSOProfileOptions())
+                .webSSOProfile(createWebSSOProfile(metadataManager, processor))
+                .webSSOProfileECP(createWebSSOProfileECP(metadataManager, processor))
+                .webSSOProfileHoK(createWebSSOProfileHok(metadataManager, processor))
+                .webSSOProfileConsumer(createWebSSOProfileConsumer(metadataManager, processor))
+                .webSSOProfileHoKConsumer(createWebSSOProfileHokConsumer(metadataManager, processor))
+                .singleLogoutProfile(createSingeLogoutProfile(metadataManager, processor))
+                .metadataManager(metadataManager)
+                .extendedMetadata(extendedMetadata)
+                .backgroundTaskTimer(backgroundTaskTimer)
+                .keyManager(keyManager)
+                .authExpiration(authExpiration)
+                .identityAttributeName(identityAttributeName)
+                .groupAttributeName(groupAttributeName)
+                .requestSigningEnabled(properties.isSamlRequestSigningEnabled())
+                .wantAssertionsSigned(properties.isSamlWantAssertionsSigned())
+                .build();
+    }
+
+    private static ParserPool createParserPool() throws XMLParserException {
+        final StaticBasicParserPool parserPool = new StaticBasicParserPool();
+        parserPool.initialize();
+        return parserPool;
+    }
+
+    private static HttpClient createHttpClient(final int connectTimeout, final int readTimeout) {
+        final HttpClientParams clientParams = new HttpClientParams();
+        clientParams.setParameter(HttpConnectionParams.CONNECTION_TIMEOUT, connectTimeout);
+        clientParams.setParameter(HttpConnectionParams.SO_TIMEOUT, readTimeout);
+
+        final HttpClient httpClient = new HttpClient(clientParams);
+        return httpClient;
+    }
+
+    private static void configureCustomTLSSocketFactory(final TlsConfiguration tlsConfiguration) throws TlsException {
+        final SSLSocketFactory sslSocketFactory = SslContextFactory.createSSLSocketFactory(tlsConfiguration);
+        final ProtocolSocketFactory socketFactory = new CustomTLSProtocolSocketFactory(sslSocketFactory);
+
+        // Consider not using global registration of protocol here as it would potentially impact other uses of commons http client
+        // with in nifi-framework-nar, currently there are no other usages, see https://hc.apache.org/httpclient-3.x/sslguide.html
+        final Protocol p = new Protocol("https", socketFactory, 443);
+        Protocol.registerProtocol(p.getScheme(), p);
+    }
+
+    private static SAMLProcessor createSAMLProcessor(final ParserPool parserPool, final VelocityEngine velocityEngine, final HttpClient httpClient) {
+        final HTTPSOAP11Binding httpsoap11Binding = new HTTPSOAP11Binding(parserPool);
+        final HTTPPAOS11Binding httppaos11Binding = new HTTPPAOS11Binding(parserPool);
+        final HTTPPostBinding httpPostBinding = new HTTPPostBinding(parserPool, velocityEngine);
+        final HTTPRedirectDeflateBinding httpRedirectDeflateBinding = new HTTPRedirectDeflateBinding(parserPool);
+
+        final ArtifactResolutionProfileImpl artifactResolutionProfile = new ArtifactResolutionProfileImpl(httpClient);
+        artifactResolutionProfile.setProcessor(new SAMLProcessorImpl(httpsoap11Binding));
+
+        final HTTPArtifactBinding httpArtifactBinding = new HTTPArtifactBinding(
+                parserPool, velocityEngine, artifactResolutionProfile);
+
+        final Collection<SAMLBinding> bindings = new ArrayList<>();
+        bindings.add(httpRedirectDeflateBinding);
+        bindings.add(httpPostBinding);
+        bindings.add(httpArtifactBinding);
+        bindings.add(httpsoap11Binding);
+        bindings.add(httppaos11Binding);
+
+        return new SAMLProcessorImpl(bindings);
+    }
+
+    private static NiFiSAMLContextProvider createContextProvider(final MetadataManager metadataManager, final KeyManager keyManager) throws ServletException {
+        final NiFiSAMLContextProviderImpl contextProvider = new NiFiSAMLContextProviderImpl();
+        contextProvider.setMetadata(metadataManager);
+        contextProvider.setKeyManager(keyManager);
+        contextProvider.afterPropertiesSet();
+        return contextProvider;
+    }
+
+    private static WebSSOProfileOptions createWebSSOProfileOptions() {
+        final WebSSOProfileOptions webSSOProfileOptions = new WebSSOProfileOptions();
+        webSSOProfileOptions.setIncludeScoping(false);
+        return webSSOProfileOptions;
+    }
+
+    private static WebSSOProfile createWebSSOProfile(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final WebSSOProfileImpl webSSOProfile = new WebSSOProfileImpl(processor, metadataManager);
+        webSSOProfile.afterPropertiesSet();
+        return webSSOProfile;
+    }
+
+    private static WebSSOProfile createWebSSOProfileECP(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final WebSSOProfileECPImpl webSSOProfileECP = new WebSSOProfileECPImpl();
+        webSSOProfileECP.setProcessor(processor);
+        webSSOProfileECP.setMetadata(metadataManager);
+        webSSOProfileECP.afterPropertiesSet();
+        return webSSOProfileECP;
+    }
+
+    private static WebSSOProfile createWebSSOProfileHok(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final WebSSOProfileHoKImpl webSSOProfileHok = new WebSSOProfileHoKImpl();
+        webSSOProfileHok.setProcessor(processor);
+        webSSOProfileHok.setMetadata(metadataManager);
+        webSSOProfileHok.afterPropertiesSet();
+        return webSSOProfileHok;
+    }
+
+    private static WebSSOProfileConsumer createWebSSOProfileConsumer(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final WebSSOProfileConsumerImpl webSSOProfileConsumer = new WebSSOProfileConsumerImpl();
+        webSSOProfileConsumer.setProcessor(processor);
+        webSSOProfileConsumer.setMetadata(metadataManager);
+        webSSOProfileConsumer.afterPropertiesSet();
+        return webSSOProfileConsumer;
+    }
+
+    private static WebSSOProfileConsumer createWebSSOProfileHokConsumer(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final WebSSOProfileConsumerHoKImpl webSSOProfileHoKConsumer = new WebSSOProfileConsumerHoKImpl();
+        webSSOProfileHoKConsumer.setProcessor(processor);
+        webSSOProfileHoKConsumer.setMetadata(metadataManager);
+        webSSOProfileHoKConsumer.afterPropertiesSet();
+        return webSSOProfileHoKConsumer;
+    }
+
+    private static SingleLogoutProfile createSingeLogoutProfile(final MetadataManager metadataManager, final SAMLProcessor processor) throws Exception {
+        final SingleLogoutProfileImpl singleLogoutProfile = new SingleLogoutProfileImpl();
+        singleLogoutProfile.setProcessor(processor);
+        singleLogoutProfile.setMetadata(metadataManager);
+        singleLogoutProfile.afterPropertiesSet();
+        return singleLogoutProfile;
+    }
+
+    private static SAMLLogger createSAMLLogger(final NiFiProperties properties) {
+        final SAMLDefaultLogger samlLogger = new SAMLDefaultLogger();
+        if (properties.isSamlMessageLoggingEnabled()) {
+            samlLogger.setLogAllMessages(true);
+            samlLogger.setLogErrors(true);
+            samlLogger.setLogMessagesOnException(true);
+        } else {
+            samlLogger.setLogAllMessages(false);
+            samlLogger.setLogErrors(false);
+            samlLogger.setLogMessagesOnException(false);
+        }
+        return samlLogger;
+    }
+
+    private static KeyManager createKeyManager(final TlsConfiguration tlsConfiguration) throws TlsException, KeyStoreException {
+        final String keystorePath = tlsConfiguration.getKeystorePath();
+        final char[] keystorePasswordChars = tlsConfiguration.getKeystorePassword().toCharArray();
+        final String keystoreType = tlsConfiguration.getKeystoreType().getType();
+
+        final String truststorePath = tlsConfiguration.getTruststorePath();
+        final char[] truststorePasswordChars = tlsConfiguration.getTruststorePassword().toCharArray();
+        final String truststoreType = tlsConfiguration.getTruststoreType().getType();
+
+        final KeyStore keyStore = KeyStoreUtils.loadKeyStore(keystorePath, keystorePasswordChars, keystoreType);
+        final KeyStore trustStore = KeyStoreUtils.loadTrustStore(truststorePath, truststorePasswordChars, truststoreType);
+
+        final String keyAlias = getPrivateKeyAlias(keyStore, keystorePath);
+        LOGGER.info("Default key alias = {}", keyAlias);
+
+        // if no key password was provided, then assume the keystore password is the same as the key password.
+        final String keyPassword = StringUtils.isBlank(tlsConfiguration.getKeyPassword()) ? tlsConfiguration.getKeystorePassword() : tlsConfiguration.getKeyPassword();
+
+        final Map<String,String> keyPasswords = new HashMap<>();
+        if (!StringUtils.isBlank(keyPassword)) {
+            keyPasswords.put(keyAlias, keyPassword);
+        }
+
+        final KeyManager keystoreKeyManager = new JKSKeyManager(keyStore, keyPasswords, keyAlias);
+        final KeyManager truststoreKeyManager = new JKSKeyManager(trustStore, Collections.emptyMap(), null);
+        return new CompositeKeyManager(keystoreKeyManager, truststoreKeyManager);
+    }
+
+    private static String getPrivateKeyAlias(final KeyStore keyStore, final String keystorePath) throws KeyStoreException {
+        final Set<String> keyAliases = getKeyAliases(keyStore);
+
+        int privateKeyAliases = 0;
+        for (final String keyAlias : keyAliases) {
+            if (keyStore.isKeyEntry(keyAlias)) {
+                privateKeyAliases++;
+            }
+        }
+
+        if (privateKeyAliases == 0) {
+            throw new RuntimeException("Unable to determine signing key, the keystore '" + keystorePath + "' does not contain any private keys");
+        }
+
+        if (privateKeyAliases > 1) {
+            throw new RuntimeException("Unable to determine signing key, the keystore '" + keystorePath + "' contains more than one private key");
+        }
+
+        String firstPrivateKeyAlias = null;
+        for (final String keyAlias : keyAliases) {
+            if (keyStore.isKeyEntry(keyAlias)) {
+                firstPrivateKeyAlias = keyAlias;
+                break;
+            }
+        }
+
+        return firstPrivateKeyAlias;
+    }
+
+    private static Set<String> getKeyAliases(final KeyStore keyStore) throws KeyStoreException {
+        final Set<String> availableKeys = new HashSet<String>();
+        Enumeration<String> aliases = keyStore.aliases();
+        while (aliases.hasMoreElements()) {
+            availableKeys.add(aliases.nextElement());
+        }
+        return availableKeys;
+    }
+
+    private static ExtendedMetadata createExtendedMetadata(final String signingAlgorithm, final boolean signMetadata) {
+        final ExtendedMetadata extendedMetadata = new ExtendedMetadata();
+        extendedMetadata.setIdpDiscoveryEnabled(true);
+        extendedMetadata.setSigningAlgorithm(signingAlgorithm);
+        extendedMetadata.setSignMetadata(signMetadata);
+        extendedMetadata.setEcpEnabled(true);
+        return extendedMetadata;
+    }
+
+    private static MetadataProvider createIdpMetadataProvider(final URI idpMetadataLocation, final HttpClient httpClient,
+                                                              final Timer timer, final ParserPool parserPool) throws Exception {
+        if (idpMetadataLocation.getScheme().startsWith("http")) {
+            return createHttpIdpMetadataProvider(idpMetadataLocation, httpClient, timer, parserPool);
+        } else {
+            return createFileIdpMetadataProvider(idpMetadataLocation, parserPool);
+        }
+    }
+
+    private static MetadataProvider createFileIdpMetadataProvider(final URI idpMetadataLocation, final ParserPool parserPool)
+            throws MetadataProviderException {
+        final String idpMetadataFilePath = idpMetadataLocation.getPath();
+        final File idpMetadataFile = new File(idpMetadataFilePath);
+        LOGGER.info("Loading IDP metadata from file located at: " + idpMetadataFile.getAbsolutePath());
+
+        final FilesystemMetadataProvider filesystemMetadataProvider = new FilesystemMetadataProvider(idpMetadataFile);
+        filesystemMetadataProvider.setParserPool(parserPool);
+        filesystemMetadataProvider.initialize();
+        return filesystemMetadataProvider;
+    }
+
+    private static MetadataProvider createHttpIdpMetadataProvider(final URI idpMetadataLocation, final HttpClient httpClient,
+                                                                  final Timer timer, final ParserPool parserPool) throws Exception {
+        final String idpMetadataUrl = idpMetadataLocation.toString();
+        final HTTPMetadataProvider httpMetadataProvider = new HTTPMetadataProvider(timer, httpClient, idpMetadataUrl);
+        httpMetadataProvider.setParserPool(parserPool);
+        httpMetadataProvider.initialize();
+        return httpMetadataProvider;
+    }
+
+    private static MetadataManager createMetadataManager(final MetadataProvider idpMetadataProvider, final ExtendedMetadata extendedMetadata, final KeyManager keyManager)
+            throws MetadataProviderException {
+        final ExtendedMetadataDelegate idpExtendedMetadataDelegate = new ExtendedMetadataDelegate(idpMetadataProvider, extendedMetadata);
+        idpExtendedMetadataDelegate.setMetadataTrustCheck(true);
+        idpExtendedMetadataDelegate.setMetadataRequireSignature(false);
+
+        final MetadataManager metadataManager = new CachingMetadataManager(Arrays.asList(idpExtendedMetadataDelegate));
+        metadataManager.setKeyManager(keyManager);
+        metadataManager.afterPropertiesSet();
+        return metadataManager;
+    }
+
+    private static void configureGlobalSecurityDefaults(final KeyManager keyManager, final String signingAlgorithm, final String digestAlgorithm) {
+        final BasicSecurityConfiguration securityConfiguration = (BasicSecurityConfiguration) Configuration.getGlobalSecurityConfiguration();
+
+        if (!StringUtils.isBlank(signingAlgorithm)) {
+            final Credential defaultCredential = keyManager.getDefaultCredential();
+            final Key signingKey = SecurityHelper.extractSigningKey(defaultCredential);
+
+            // ensure that the requested signature algorithm can be produced by the type of key we have (i.e. RSA key -> rsa-sha1 signature)
+            final String keyAlgorithm = signingKey.getAlgorithm();
+            if (!signingAlgorithm.contains(keyAlgorithm.toLowerCase())) {
+                throw new IllegalStateException("Key algorithm '" + keyAlgorithm + "' cannot be used to create signatures of type '" + signingAlgorithm + "'");
+            }
+
+            securityConfiguration.registerSignatureAlgorithmURI(keyAlgorithm, signingAlgorithm);
+        }
+
+        if (!StringUtils.isBlank(digestAlgorithm)) {
+            securityConfiguration.setSignatureReferenceDigestMethod(digestAlgorithm);
+        }
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLCredentialStore.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLCredentialStore.java
new file mode 100644
index 0000000..33506e4
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLCredentialStore.java
@@ -0,0 +1,124 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.nifi.admin.service.IdpCredentialService;
+import org.apache.nifi.idp.IdpCredential;
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.web.security.saml.SAMLCredentialStore;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.saml.SAMLCredential;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+
+/**
+ * Standard implementation of SAMLCredentialStore that uses Java serialization to store
+ * SAMLCredential objects as BLOBs in a relational database.
+ */
+public class StandardSAMLCredentialStore implements SAMLCredentialStore {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(StandardSAMLCredentialStore.class);
+
+    private final IdpCredentialService idpCredentialService;
+
+    public StandardSAMLCredentialStore(final IdpCredentialService idpCredentialService) {
+        this.idpCredentialService = idpCredentialService;
+    }
+
+    @Override
+    public void save(final String identity, final SAMLCredential credential) {
+        if (StringUtils.isBlank(identity)) {
+            throw new IllegalArgumentException("Identity cannot be null");
+        }
+
+        if (credential == null) {
+            throw new IllegalArgumentException("Credential cannot be null");
+        }
+
+        try (final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+             final ObjectOutputStream objOut = new ObjectOutputStream(baos)) {
+
+            objOut.writeObject(credential);
+            objOut.flush();
+            baos.flush();
+
+            final IdpCredential idpCredential = new IdpCredential();
+            idpCredential.setIdentity(identity);
+            idpCredential.setType(IdpType.SAML);
+            idpCredential.setCredential(baos.toByteArray());
+
+            // replace issues a delete first in case the user already has a stored credential that wasn't properly cleaned up on logout
+            final IdpCredential createdIdpCredential = idpCredentialService.replaceCredential(idpCredential);
+            LOGGER.debug("Successfully saved SAMLCredential for {} with id {}", identity, createdIdpCredential.getId());
+
+        } catch (IOException e) {
+            throw new RuntimeException("Unable to serialize SAMLCredential for user with identity " + identity, e);
+        }
+    }
+
+    @Override
+    public SAMLCredential get(final String identity) {
+        final IdpCredential idpCredential = idpCredentialService.getCredential(identity);
+        if (idpCredential == null) {
+            LOGGER.debug("No SAMLCredential exists for {}", identity);
+            return null;
+        }
+
+        final IdpType idpType = idpCredential.getType();
+        if (idpType != IdpType.SAML) {
+            LOGGER.debug("Stored credential for {} was not a SAML credential, type was {}", identity, idpType);
+            return null;
+        }
+
+        final byte[] serializedCredential = idpCredential.getCredential();
+
+        try (final ByteArrayInputStream bais = new ByteArrayInputStream(serializedCredential);
+             final ObjectInputStream objIn = new ObjectInputStream(bais)) {
+
+            final SAMLCredential samlCredential = (SAMLCredential) objIn.readObject();
+            return samlCredential;
+
+        } catch (IOException | ClassNotFoundException e) {
+            throw new RuntimeException("Unable to deserialize SAMLCredential for user with identity " + identity, e);
+        }
+    }
+
+    @Override
+    public void delete(final String identity) {
+        final IdpCredential idpCredential = idpCredentialService.getCredential(identity);
+
+        if (idpCredential == null) {
+            LOGGER.debug("No SAMLCredential exists for {}", identity);
+            return;
+        }
+
+        final IdpType idpType = idpCredential.getType();
+        if (idpType != IdpType.SAML) {
+            LOGGER.debug("Stored credential for {} was not a SAML credential, type was {}", identity, idpType);
+            return;
+        }
+
+        idpCredentialService.deleteCredential(idpCredential.getId());
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLService.java
new file mode 100644
index 0000000..d1a1e97
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLService.java
@@ -0,0 +1,528 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.nifi.util.NiFiProperties;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.web.security.saml.NiFiSAMLContextProvider;
+import org.apache.nifi.web.security.saml.SAMLConfiguration;
+import org.apache.nifi.web.security.saml.SAMLConfigurationFactory;
+import org.apache.nifi.web.security.saml.SAMLEndpoints;
+import org.apache.nifi.web.security.saml.SAMLService;
+import org.opensaml.common.SAMLException;
+import org.opensaml.common.SAMLRuntimeException;
+import org.opensaml.common.binding.decoding.URIComparator;
+import org.opensaml.saml2.core.Attribute;
+import org.opensaml.saml2.core.LogoutRequest;
+import org.opensaml.saml2.core.LogoutResponse;
+import org.opensaml.saml2.metadata.Endpoint;
+import org.opensaml.saml2.metadata.EntityDescriptor;
+import org.opensaml.saml2.metadata.provider.MetadataProvider;
+import org.opensaml.saml2.metadata.provider.MetadataProviderException;
+import org.opensaml.xml.XMLObject;
+import org.opensaml.xml.encryption.DecryptionException;
+import org.opensaml.xml.schema.XSString;
+import org.opensaml.xml.validation.ValidationException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.saml.SAMLConstants;
+import org.springframework.security.saml.SAMLCredential;
+import org.springframework.security.saml.SAMLLogoutProcessingFilter;
+import org.springframework.security.saml.SAMLProcessingFilter;
+import org.springframework.security.saml.context.SAMLMessageContext;
+import org.springframework.security.saml.key.KeyManager;
+import org.springframework.security.saml.log.SAMLLogger;
+import org.springframework.security.saml.metadata.ExtendedMetadata;
+import org.springframework.security.saml.metadata.ExtendedMetadataDelegate;
+import org.springframework.security.saml.metadata.MetadataGenerator;
+import org.springframework.security.saml.metadata.MetadataManager;
+import org.springframework.security.saml.metadata.MetadataMemoryProvider;
+import org.springframework.security.saml.processor.SAMLProcessor;
+import org.springframework.security.saml.util.DefaultURLComparator;
+import org.springframework.security.saml.util.SAMLUtil;
+import org.springframework.security.saml.websso.SingleLogoutProfile;
+import org.springframework.security.saml.websso.WebSSOProfile;
+import org.springframework.security.saml.websso.WebSSOProfileConsumer;
+import org.springframework.security.saml.websso.WebSSOProfileOptions;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.Timer;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class StandardSAMLService implements SAMLService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(StandardSAMLService.class);
+
+    private final NiFiProperties properties;
+    private final SAMLConfigurationFactory samlConfigurationFactory;
+
+    private final AtomicBoolean initialized = new AtomicBoolean(false);
+    private final AtomicBoolean spMetadataInitialized = new AtomicBoolean(false);
+    private final AtomicReference<String> spBaseUrl = new AtomicReference<>(null);
+    private final URIComparator uriComparator = new DefaultURLComparator();
+
+    private SAMLConfiguration samlConfiguration;
+
+
+    public StandardSAMLService(final SAMLConfigurationFactory samlConfigurationFactory, final NiFiProperties properties) {
+        this.properties = properties;
+        this.samlConfigurationFactory = samlConfigurationFactory;
+    }
+
+    @Override
+    public synchronized void initialize() {
+        // this method will always be called so if SAML is not configured just return, don't throw an exception
+        if (!properties.isSamlEnabled()) {
+            return;
+        }
+
+        // already initialized so return
+        if (initialized.get()) {
+            return;
+        }
+
+        try {
+            LOGGER.info("Initializing SAML Service...");
+            samlConfiguration = samlConfigurationFactory.create(properties);
+            initialized.set(true);
+            LOGGER.info("Finished initializing SAML Service");
+        } catch (Exception e) {
+            throw new RuntimeException("Unable to initialize SAML configuration due to: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public void shutdown() {
+        // this method will always be called so if SAML is not configured just return, don't throw an exception
+        if (!properties.isSamlEnabled()) {
+            return;
+        }
+
+        LOGGER.info("Shutting down SAML Service...");
+
+        if (samlConfiguration != null) {
+            try {
+                final Timer backgroundTimer = samlConfiguration.getBackgroundTaskTimer();
+                backgroundTimer.purge();
+                backgroundTimer.cancel();
+            } catch (final Exception e) {
+                LOGGER.warn("Error shutting down background timer: " + e.getMessage(), e);
+            }
+
+            try {
+                final MetadataManager metadataManager = samlConfiguration.getMetadataManager();
+                metadataManager.destroy();
+            } catch (final Exception e) {
+                LOGGER.warn("Error shutting down metadata manager: " + e.getMessage(), e);
+            }
+        }
+
+        samlConfiguration = null;
+        initialized.set(false);
+        spMetadataInitialized.set(false);
+        spBaseUrl.set(null);
+
+        LOGGER.info("Finished shutting down SAML Service");
+    }
+
+    @Override
+    public boolean isSamlEnabled() {
+        return properties.isSamlEnabled();
+    }
+
+    @Override
+    public boolean isServiceProviderInitialized() {
+        return spMetadataInitialized.get();
+    }
+
+    @Override
+    public synchronized void initializeServiceProvider(final String baseUrl) {
+        if (!isSamlEnabled()) {
+            throw new IllegalStateException(SAML_SUPPORT_IS_NOT_CONFIGURED);
+        }
+
+        if (StringUtils.isBlank(baseUrl)) {
+            throw new IllegalArgumentException("baseUrl is required when initializing the service provider");
+        }
+
+        if (isServiceProviderInitialized()) {
+            final String existingBaseUrl = spBaseUrl.get();
+            LOGGER.info("Service provider already initialized with baseUrl = '{}'", new Object[]{existingBaseUrl});
+            return;
+        }
+
+        LOGGER.info("Initializing SAML service provider with baseUrl = '{}'", new Object[]{baseUrl});
+        try {
+            initializeServiceProviderMetadata(baseUrl);
+            spBaseUrl.set(baseUrl);
+            spMetadataInitialized.set(true);
+        } catch (Exception e) {
+            throw new RuntimeException("Unable to initialize SAML service provider: " + e.getMessage(), e);
+        }
+        LOGGER.info("Done initializing SAML service provider");
+    }
+
+    @Override
+    public String getServiceProviderMetadata() {
+        verifyReadyForSamlOperations();
+        try {
+            final KeyManager keyManager = samlConfiguration.getKeyManager();
+            final MetadataManager metadataManager = samlConfiguration.getMetadataManager();
+
+            final String spEntityId = samlConfiguration.getSpEntityId();
+            final EntityDescriptor descriptor = metadataManager.getEntityDescriptor(spEntityId);
+
+            final String metadataString = SAMLUtil.getMetadataAsString(metadataManager, keyManager, descriptor, null);
+            return metadataString;
+        } catch (Exception e) {
+            throw new RuntimeException("Unable to obtain SAML service provider metadata", e);
+        }
+    }
+
+    @Override
+    public long getAuthExpiration() {
+        verifyReadyForSamlOperations();
+        return samlConfiguration.getAuthExpiration();
+    }
+
+    @Override
+    public void initiateLogin(final HttpServletRequest request, final HttpServletResponse response, final String relayState) {
+        verifyReadyForSamlOperations();
+
+        final SAMLLogger samlLogger = samlConfiguration.getLogger();
+        final NiFiSAMLContextProvider contextProvider = samlConfiguration.getContextProvider();
+
+        final SAMLMessageContext context;
+        try {
+            context = contextProvider.getLocalAndPeerEntity(request, response, Collections.emptyMap());
+        } catch (final MetadataProviderException e) {
+            throw new IllegalStateException("Unable to create SAML Message Context: " + e.getMessage(), e);
+        }
+
+        // Generate options for the current SSO request
+        final WebSSOProfileOptions options = samlConfiguration.getWebSSOProfileOptions().clone();
+        options.setRelayState(relayState);
+
+        // Send WebSSO AuthN request
+        final WebSSOProfile webSSOProfile = samlConfiguration.getWebSSOProfile();
+        try {
+            webSSOProfile.sendAuthenticationRequest(context, options);
+            samlLogger.log(SAMLConstants.AUTH_N_REQUEST, SAMLConstants.SUCCESS, context);
+        } catch (Exception e) {
+            samlLogger.log(SAMLConstants.AUTH_N_REQUEST, SAMLConstants.FAILURE, context);
+            throw new RuntimeException("Unable to initiate SAML authentication request: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public SAMLCredential processLogin(final HttpServletRequest request, final HttpServletResponse response, final Map<String,String> parameters) {
+        verifyReadyForSamlOperations();
+
+        LOGGER.info("Attempting SAML2 authentication using profile {}", SAMLConstants.SAML2_WEBSSO_PROFILE_URI);
+
+        final SAMLMessageContext context;
+        try {
+            final NiFiSAMLContextProvider contextProvider = samlConfiguration.getContextProvider();
+            context = contextProvider.getLocalEntity(request, response, parameters);
+        } catch (MetadataProviderException e) {
+            throw new IllegalStateException("Unable to create SAML Message Context: " + e.getMessage(), e);
+        }
+
+        final SAMLProcessor samlProcessor = samlConfiguration.getProcessor();
+        try {
+            samlProcessor.retrieveMessage(context);
+        } catch (Exception e) {
+            throw new RuntimeException("Unable to load SAML message: " + e.getMessage(), e);
+        }
+
+        // Override set values
+        context.setCommunicationProfileId(SAMLConstants.SAML2_WEBSSO_PROFILE_URI);
+        try {
+            context.setLocalEntityEndpoint(getLocalEntityEndpoint(context));
+        } catch (SAMLException e) {
+            throw new RuntimeException(e.getMessage(), e);
+        }
+
+        if (!SAMLConstants.SAML2_WEBSSO_PROFILE_URI.equals(context.getCommunicationProfileId())) {
+            throw new IllegalStateException("Unsupported profile encountered in the context: " + context.getCommunicationProfileId());
+        }
+
+        final SAMLLogger samlLogger = samlConfiguration.getLogger();
+        final WebSSOProfileConsumer webSSOProfileConsumer = samlConfiguration.getWebSSOProfileConsumer();
+
+        try {
+            final SAMLCredential credential = webSSOProfileConsumer.processAuthenticationResponse(context);
+            LOGGER.debug("SAML Response contains successful authentication for NameID: " + credential.getNameID().getValue());
+            samlLogger.log(SAMLConstants.AUTH_N_RESPONSE, SAMLConstants.SUCCESS, context);
+            return credential;
+        } catch (SAMLException | SAMLRuntimeException e) {
+            LOGGER.error("Error validating SAML message", e);
+            samlLogger.log(SAMLConstants.AUTH_N_RESPONSE, SAMLConstants.FAILURE, context, e);
+            throw new RuntimeException("Error validating SAML message: " + e.getMessage(), e);
+        } catch (org.opensaml.xml.security.SecurityException | ValidationException e) {
+            LOGGER.error("Error validating signature", e);
+            samlLogger.log(SAMLConstants.AUTH_N_RESPONSE, SAMLConstants.FAILURE, context, e);
+            throw new RuntimeException("Error validating SAML message signature: " + e.getMessage(), e);
+        } catch (DecryptionException e) {
+            LOGGER.error("Error decrypting SAML message", e);
+            samlLogger.log(SAMLConstants.AUTH_N_RESPONSE, SAMLConstants.FAILURE, context, e);
+            throw new RuntimeException("Error decrypting SAML message: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public String getUserIdentity(final SAMLCredential credential) {
+        verifyReadyForSamlOperations();
+
+        if (credential == null) {
+            throw new IllegalArgumentException("SAML Credential is required");
+        }
+
+        String userIdentity = null;
+
+        final String identityAttributeName = samlConfiguration.getIdentityAttributeName();
+        if (StringUtils.isBlank(identityAttributeName)) {
+            userIdentity = credential.getNameID().getValue();
+            LOGGER.info("No identity attribute specified, using NameID for user identity: {}", userIdentity);
+        } else {
+            LOGGER.debug("Looking for SAML attribute {} ...", identityAttributeName);
+
+            final List<Attribute> attributes = credential.getAttributes();
+            if (attributes == null || attributes.isEmpty()) {
+                userIdentity = credential.getNameID().getValue();
+                LOGGER.warn("No attributes returned in SAML response, using NameID for user identity: {}", userIdentity);
+            } else {
+                for (final Attribute attribute : attributes) {
+                    if (!identityAttributeName.equals(attribute.getName())) {
+                        LOGGER.trace("Skipping SAML attribute {}", attribute.getName());
+                        continue;
+                    }
+
+                    for (final XMLObject value : attribute.getAttributeValues()) {
+                        if (value instanceof XSString) {
+                            final XSString valueXSString = (XSString) value;
+                            userIdentity = valueXSString.getValue();
+                            break;
+                        } else {
+                            LOGGER.debug("Value was not XSString, but was " + value.getClass().getCanonicalName());
+                        }
+                    }
+
+                    if (userIdentity != null) {
+                        LOGGER.info("Found user identity {} in attribute {}", userIdentity, attribute.getName());
+                        break;
+                    }
+                }
+            }
+
+            if (userIdentity == null) {
+                userIdentity = credential.getNameID().getValue();
+                LOGGER.warn("No attribute found named {}, using NameID for user identity: {}", identityAttributeName, userIdentity);
+            }
+        }
+
+        return userIdentity;
+    }
+
+    @Override
+    public Set<String> getUserGroups(final SAMLCredential credential) {
+        verifyReadyForSamlOperations();
+
+        if (credential == null) {
+            throw new IllegalArgumentException("SAML Credential is required");
+        }
+
+        final String userIdentity = credential.getNameID().getValue();
+        final String groupAttributeName = samlConfiguration.getGroupAttributeName();
+        if (StringUtils.isBlank(groupAttributeName)) {
+            LOGGER.warn("Cannot obtain groups for {} because no group attribute name has been configured", userIdentity);
+            return Collections.emptySet();
+        }
+
+        final Set<String> groups = new HashSet<>();
+        if (credential.getAttributes() != null) {
+            for (final Attribute attribute : credential.getAttributes()) {
+                if (!groupAttributeName.equals(attribute.getName())) {
+                    LOGGER.debug("Skipping SAML attribute {}", attribute.getName());
+                    continue;
+                }
+
+                for (final XMLObject value : attribute.getAttributeValues()) {
+                    if (value instanceof XSString) {
+                        final XSString valueXSString = (XSString) value;
+                        final String groupName = valueXSString.getValue();
+                        LOGGER.debug("Found group {} for {}", groupName, userIdentity);
+                        groups.add(groupName);
+                    } else {
+                        LOGGER.debug("Value was not XSString, but was " + value.getClass().getCanonicalName());
+                    }
+                }
+            }
+        }
+
+        return groups;
+    }
+
+    @Override
+    public void initiateLogout(final HttpServletRequest request, final HttpServletResponse response, final SAMLCredential credential) {
+        verifyReadyForSamlOperations();
+
+        final SAMLMessageContext context;
+        try {
+            final NiFiSAMLContextProvider contextProvider = samlConfiguration.getContextProvider();
+            context = contextProvider.getLocalAndPeerEntity(request, response, Collections.emptyMap());
+        } catch (MetadataProviderException e) {
+            throw new IllegalStateException("Unable to create SAML Message Context: " + e.getMessage(), e);
+        }
+
+        final SAMLLogger samlLogger = samlConfiguration.getLogger();
+        final SingleLogoutProfile singleLogoutProfile = samlConfiguration.getSingleLogoutProfile();
+
+        try {
+            singleLogoutProfile.sendLogoutRequest(context, credential);
+            samlLogger.log(SAMLConstants.LOGOUT_REQUEST, SAMLConstants.SUCCESS, context);
+        } catch (Exception e) {
+            samlLogger.log(SAMLConstants.LOGOUT_REQUEST, SAMLConstants.FAILURE, context);
+            throw new RuntimeException("Unable to initiate SAML logout request: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public void processLogout(final HttpServletRequest request, final HttpServletResponse response, final Map<String, String> parameters) {
+        verifyReadyForSamlOperations();
+
+        final SAMLMessageContext context;
+        try {
+            final NiFiSAMLContextProvider contextProvider = samlConfiguration.getContextProvider();
+            context = contextProvider.getLocalAndPeerEntity(request, response, parameters);
+        } catch (MetadataProviderException e) {
+            throw new IllegalStateException("Unable to create SAML Message Context: " + e.getMessage(), e);
+        }
+
+        final SAMLProcessor samlProcessor = samlConfiguration.getProcessor();
+        try {
+            samlProcessor.retrieveMessage(context);
+        } catch (Exception e) {
+            throw new RuntimeException("Unable to load SAML message: " + e.getMessage(), e);
+        }
+
+        // Override set values
+        context.setCommunicationProfileId(SAMLConstants.SAML2_SLO_PROFILE_URI);
+        try {
+            context.setLocalEntityEndpoint(getLocalEntityEndpoint(context));
+        } catch (SAMLException e) {
+            throw new RuntimeException(e.getMessage(), e);
+        }
+
+        // Determine if the incoming SAML messages is a response to a logout we initiated, or a request initiated by the IDP
+        if (context.getInboundSAMLMessage() instanceof LogoutResponse) {
+            processLogoutResponse(context);
+        } else if (context.getInboundSAMLMessage() instanceof LogoutRequest) {
+            processLogoutRequest(context);
+        }
+    }
+
+    private void processLogoutResponse(final SAMLMessageContext context) {
+        final SAMLLogger samlLogger = samlConfiguration.getLogger();
+        final SingleLogoutProfile logoutProfile = samlConfiguration.getSingleLogoutProfile();
+
+        try {
+            logoutProfile.processLogoutResponse(context);
+            samlLogger.log(SAMLConstants.LOGOUT_RESPONSE, SAMLConstants.SUCCESS, context);
+        } catch (Exception e) {
+            LOGGER.error("Received logout response is invalid", e);
+            samlLogger.log(SAMLConstants.LOGOUT_RESPONSE, SAMLConstants.FAILURE, context, e);
+            throw new RuntimeException("Received logout response is invalid: " + e.getMessage(), e);
+        }
+    }
+
+    private void processLogoutRequest(final SAMLMessageContext context) {
+        throw new UnsupportedOperationException("Apache NiFi currently does not support IDP initiated logout");
+    }
+
+    private Endpoint getLocalEntityEndpoint(final SAMLMessageContext context) throws SAMLException {
+        return SAMLUtil.getEndpoint(
+                context.getLocalEntityRoleMetadata().getEndpoints(),
+                context.getInboundSAMLBinding(),
+                context.getInboundMessageTransport(),
+                uriComparator);
+    }
+
+    private void initializeServiceProviderMetadata(final String baseUrl) throws MetadataProviderException {
+        // Create filters so MetadataGenerator can get URLs, but we don't actually use the filters, the filter
+        // paths are the URLs from AccessResource that match up with the corresponding SAML endpoint
+        final SAMLProcessingFilter ssoProcessingFilter = new SAMLProcessingFilter();
+        ssoProcessingFilter.setFilterProcessesUrl(SAMLEndpoints.LOGIN_CONSUMER);
+
+        final LogoutHandler noOpLogoutHandler = (request, response, authentication) -> {
+            return;
+        };
+        final SAMLLogoutProcessingFilter sloProcessingFilter = new SAMLLogoutProcessingFilter("/nifi", noOpLogoutHandler);
+        sloProcessingFilter.setFilterProcessesUrl(SAMLEndpoints.SINGLE_LOGOUT_CONSUMER);
+
+        // Create the MetadataGenerator...
+        final MetadataGenerator metadataGenerator = new MetadataGenerator();
+        metadataGenerator.setEntityId(samlConfiguration.getSpEntityId());
+        metadataGenerator.setEntityBaseURL(baseUrl);
+        metadataGenerator.setExtendedMetadata(samlConfiguration.getExtendedMetadata());
+        metadataGenerator.setIncludeDiscoveryExtension(false);
+        metadataGenerator.setKeyManager(samlConfiguration.getKeyManager());
+        metadataGenerator.setSamlWebSSOFilter(ssoProcessingFilter);
+        metadataGenerator.setSamlLogoutProcessingFilter(sloProcessingFilter);
+        metadataGenerator.setRequestSigned(samlConfiguration.isRequestSigningEnabled());
+        metadataGenerator.setWantAssertionSigned(samlConfiguration.isWantAssertionsSigned());
+
+        // Generate service provider metadata...
+        final EntityDescriptor descriptor = metadataGenerator.generateMetadata();
+        final ExtendedMetadata extendedMetadata = metadataGenerator.generateExtendedMetadata();
+
+        // Create the MetadataProvider to hold SP metadata
+        final MetadataMemoryProvider memoryProvider = new MetadataMemoryProvider(descriptor);
+        memoryProvider.initialize();
+
+        final MetadataProvider spMetadataProvider = new ExtendedMetadataDelegate(memoryProvider, extendedMetadata);
+
+        // Update the MetadataManager with the service provider MetadataProvider
+        final MetadataManager metadataManager = samlConfiguration.getMetadataManager();
+        metadataManager.addMetadataProvider(spMetadataProvider);
+        metadataManager.setHostedSPName(descriptor.getEntityID());
+        metadataManager.refreshMetadata();
+    }
+
+    private void verifyReadyForSamlOperations() {
+        if (!isSamlEnabled()) {
+            throw new IllegalStateException(SAML_SUPPORT_IS_NOT_CONFIGURED);
+        }
+
+        if (!initialized.get()) {
+            throw new IllegalStateException("StandardSAMLService has not been initialized");
+        }
+
+        if (!isServiceProviderInitialized()) {
+            throw new IllegalStateException("Service Provider is not initialized");
+        }
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLStateManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLStateManager.java
new file mode 100644
index 0000000..6515abe
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/StandardSAMLStateManager.java
@@ -0,0 +1,143 @@
+/*
+ * 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.saml.impl;
+
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.web.security.jwt.JwtService;
+import org.apache.nifi.web.security.saml.SAMLStateManager;
+import org.apache.nifi.web.security.token.LoginAuthenticationToken;
+import org.apache.nifi.web.security.util.CacheKey;
+import org.apache.nifi.web.security.util.IdentityProviderUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+
+public class StandardSAMLStateManager implements SAMLStateManager {
+
+    private static Logger LOGGER = LoggerFactory.getLogger(StandardSAMLStateManager.class);
+
+    private final JwtService jwtService;
+
+    // identifier from cookie -> state value
+    private final Cache<CacheKey, String> stateLookupForPendingRequests;
+
+    // identifier from cookie -> jwt or identity (and generate jwt on retrieval)
+    private final Cache<CacheKey, String> jwtLookupForCompletedRequests;
+
+    public StandardSAMLStateManager(final JwtService jwtService) {
+        this(jwtService, 60, TimeUnit.SECONDS);
+    }
+
+    public StandardSAMLStateManager(final JwtService jwtService, final int cacheExpiration, final TimeUnit units) {
+        this.jwtService = jwtService;
+        this.stateLookupForPendingRequests = CacheBuilder.newBuilder().expireAfterWrite(cacheExpiration, units).build();
+        this.jwtLookupForCompletedRequests = CacheBuilder.newBuilder().expireAfterWrite(cacheExpiration, units).build();
+    }
+
+    @Override
+    public String createState(final String requestIdentifier) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalArgumentException("Request identifier is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+        final String state = IdentityProviderUtils.generateStateValue();
+
+        try {
+            synchronized (stateLookupForPendingRequests) {
+                final String cachedState = stateLookupForPendingRequests.get(requestIdentifierKey, () -> state);
+                if (!IdentityProviderUtils.timeConstantEqualityCheck(state, cachedState)) {
+                    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;
+    }
+
+    @Override
+    public boolean isStateValid(final String requestIdentifier, final String proposedState) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalArgumentException("Request identifier is required");
+        }
+
+        if (StringUtils.isBlank(proposedState)) {
+            throw new IllegalArgumentException("Proposed state must be specified.");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+
+        synchronized (stateLookupForPendingRequests) {
+            final String state = stateLookupForPendingRequests.getIfPresent(requestIdentifierKey);
+            if (state != null) {
+                stateLookupForPendingRequests.invalidate(requestIdentifierKey);
+            }
+
+            return state != null && IdentityProviderUtils.timeConstantEqualityCheck(state, proposedState);
+        }
+    }
+
+    @Override
+    public void createJwt(final String requestIdentifier, final LoginAuthenticationToken token) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalStateException("Request identifier is required");
+        }
+
+        if (token == null) {
+            throw new IllegalArgumentException("Token is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+        final String nifiJwt = jwtService.generateSignedToken(token);
+        try {
+            // cache the jwt for later retrieval
+            synchronized (jwtLookupForCompletedRequests) {
+                final String cachedJwt = jwtLookupForCompletedRequests.get(requestIdentifierKey, () -> nifiJwt);
+                if (!IdentityProviderUtils.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.");
+        }
+    }
+
+    @Override
+    public String getJwt(final String requestIdentifier) {
+        if (StringUtils.isBlank(requestIdentifier)) {
+            throw new IllegalStateException("Request identifier is required");
+        }
+
+        final CacheKey requestIdentifierKey = new CacheKey(requestIdentifier);
+
+        synchronized (jwtLookupForCompletedRequests) {
+            final String jwt = jwtLookupForCompletedRequests.getIfPresent(requestIdentifierKey);
+            if (jwt != null) {
+                jwtLookupForCompletedRequests.invalidate(requestIdentifierKey);
+            }
+
+            return jwt;
+        }
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CompositeKeyManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CompositeKeyManager.java
new file mode 100644
index 0000000..5e75483
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CompositeKeyManager.java
@@ -0,0 +1,107 @@
+/*
+ * 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.saml.impl.tls;
+
+import org.opensaml.xml.security.CriteriaSet;
+import org.opensaml.xml.security.SecurityException;
+import org.opensaml.xml.security.credential.Credential;
+import org.springframework.security.saml.key.KeyManager;
+
+import java.security.cert.X509Certificate;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * KeyManager implementation that combines two KeyManager instances where one instance represents a keystore containing
+ * the service provider's private key (i.e. nifi's keystore.jks) and the other represents a keystore containing the
+ * trusted certificates (i.e. nifi's truststore.jks).
+ *
+ * During any call that requires resolution of a Credential, the server KeyManager is always checked first, if nothing
+ * is found then the trust KeyManager is checked.
+ *
+ * The default Credential is considered that default Credential from the server KeyManager.
+ */
+public class CompositeKeyManager implements KeyManager {
+
+    private final KeyManager serverKeyManager;
+    private final KeyManager trustKeyManager;
+
+    public CompositeKeyManager(final KeyManager serverKeyManager, final KeyManager trustKeyManager) {
+        this.serverKeyManager = Objects.requireNonNull(serverKeyManager);
+        this.trustKeyManager = Objects.requireNonNull(trustKeyManager);
+    }
+
+    @Override
+    public Credential getCredential(String keyName) {
+        if (keyName == null) {
+            return serverKeyManager.getDefaultCredential();
+        }
+
+        Credential credential = serverKeyManager.getCredential(keyName);
+        if (credential == null) {
+            credential = trustKeyManager.getCredential(keyName);
+        }
+
+        return credential;
+    }
+
+    @Override
+    public Credential getDefaultCredential() {
+        return serverKeyManager.getDefaultCredential();
+    }
+
+    @Override
+    public String getDefaultCredentialName() {
+        return serverKeyManager.getDefaultCredentialName();
+    }
+
+    @Override
+    public Set<String> getAvailableCredentials() {
+        final Set<String> allCredentials = new HashSet<>();
+        allCredentials.addAll(serverKeyManager.getAvailableCredentials());
+        allCredentials.addAll(trustKeyManager.getAvailableCredentials());
+        return allCredentials;
+    }
+
+    @Override
+    public X509Certificate getCertificate(String alias) {
+        X509Certificate certificate = serverKeyManager.getCertificate(alias);
+        if (certificate == null) {
+            certificate = trustKeyManager.getCertificate(alias);
+        }
+        return certificate;
+    }
+
+    @Override
+    public Iterable<Credential> resolve(CriteriaSet criteria) throws SecurityException {
+        Iterable<Credential> credentials = serverKeyManager.resolve(criteria);
+        if (credentials == null || !credentials.iterator().hasNext()) {
+            credentials = trustKeyManager.resolve(criteria);
+        }
+        return credentials;
+    }
+
+    @Override
+    public Credential resolveSingle(CriteriaSet criteria) throws SecurityException {
+        Credential credential = serverKeyManager.resolveSingle(criteria);
+        if (credential == null) {
+            trustKeyManager.resolveSingle(criteria);
+        }
+        return credential;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CustomTLSProtocolSocketFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CustomTLSProtocolSocketFactory.java
new file mode 100644
index 0000000..0e584c7
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/CustomTLSProtocolSocketFactory.java
@@ -0,0 +1,69 @@
+/*
+ * 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.saml.impl.tls;
+
+import org.apache.commons.httpclient.params.HttpConnectionParams;
+import org.apache.commons.httpclient.protocol.SecureProtocolSocketFactory;
+
+import javax.net.ssl.SSLSocketFactory;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.SocketAddress;
+
+public class CustomTLSProtocolSocketFactory implements SecureProtocolSocketFactory {
+
+    private final SSLSocketFactory sslSocketFactory;
+
+    public CustomTLSProtocolSocketFactory(final SSLSocketFactory sslSocketFactory) {
+        this.sslSocketFactory = sslSocketFactory;
+    }
+
+    @Override
+    public Socket createSocket(Socket socket, String host, int port, boolean autoClose) throws IOException {
+       return sslSocketFactory.createSocket(socket, host, port, autoClose);
+    }
+
+    @Override
+    public Socket createSocket(String host, int port, InetAddress localAddress, int localPort) throws IOException {
+        return sslSocketFactory.createSocket(host, port, localAddress, localPort);
+    }
+
+    @Override
+    public Socket createSocket(String host, int port, InetAddress localAddress, int localPort, HttpConnectionParams params) throws IOException {
+        if (params == null) {
+            throw new IllegalArgumentException("Parameters may not be null");
+        }
+        int timeout = params.getConnectionTimeout();
+        if (timeout == 0) {
+            return sslSocketFactory.createSocket(host, port, localAddress, localPort);
+        } else {
+            Socket socket = sslSocketFactory.createSocket();
+            SocketAddress localaddr = new InetSocketAddress(localAddress, localPort);
+            SocketAddress remoteaddr = new InetSocketAddress(host, port);
+            socket.bind(localaddr);
+            socket.connect(remoteaddr, timeout);
+            return socket;
+        }
+    }
+
+    @Override
+    public Socket createSocket(String host, int port) throws IOException {
+        return sslSocketFactory.createSocket(host, port);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/TruststoreStrategy.java
similarity index 71%
copy from nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
copy to nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/TruststoreStrategy.java
index 3fcc6d8..0921f02 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-administration/src/main/java/org/apache/nifi/admin/dao/DAOFactory.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/saml/impl/tls/TruststoreStrategy.java
@@ -14,14 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.admin.dao;
+package org.apache.nifi.web.security.saml.impl.tls;
 
 /**
- *
+ * Indicates which truststore should be used when creating an HttpClient for an https URL.
  */
-public interface DAOFactory {
+public enum TruststoreStrategy {
 
-    ActionDAO getActionDAO();
+    /**
+     * Use the JDK truststore.
+     */
+    JDK,
 
-    KeyDAO getKeyDAO();
+    /**
+     * Use NiFi's truststore specified in nifi.properties.
+     */
+    NIFI;
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/CacheKey.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/CacheKey.java
index 6247993..e7258c1 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/CacheKey.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/CacheKey.java
@@ -20,9 +20,6 @@ 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 {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java
new file mode 100644
index 0000000..e075c3b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/util/IdentityProviderUtils.java
@@ -0,0 +1,52 @@
+/*
+ * 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.util;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+
+public class IdentityProviderUtils {
+
+    /**
+     * Generates a value to use as State in an identity provider 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
+     */
+    public static String generateStateValue() {
+        return new BigInteger(130, new SecureRandom()).toString(32);
+    }
+
+    /**
+     * 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
+     */
+    public static 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-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
index c8c839d..4fcdad7 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationFilter.java
@@ -49,7 +49,13 @@ public class X509AuthenticationFilter extends NiFiAuthenticationFilter {
             return null;
         }
 
-        return new X509AuthenticationRequestToken(request.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN), principalExtractor, certificates, request.getRemoteAddr());
+        final String proxiedEntitiesChain = request.getHeader(ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN);
+        logger.debug("Raw {} - {}", ProxiedEntitiesUtils.PROXY_ENTITIES_CHAIN, proxiedEntitiesChain);
+
+        final String proxiedEntityIdpGroups = request.getHeader(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS);
+        logger.debug("Raw {} - {}", ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS, proxiedEntityIdpGroups);
+
+        return new X509AuthenticationRequestToken(proxiedEntitiesChain, proxiedEntityIdpGroups, principalExtractor, certificates, request.getRemoteAddr());
     }
 
     /* setters */
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationProvider.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationProvider.java
index a56f9dd..a019d14 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationProvider.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationProvider.java
@@ -22,7 +22,6 @@ import org.apache.nifi.authorization.AccessDeniedException;
 import org.apache.nifi.authorization.Authorizer;
 import org.apache.nifi.authorization.RequestAction;
 import org.apache.nifi.authorization.Resource;
-import org.apache.nifi.authorization.UserContextKeys;
 import org.apache.nifi.authorization.resource.Authorizable;
 import org.apache.nifi.authorization.resource.ResourceFactory;
 import org.apache.nifi.authorization.user.NiFiUser;
@@ -35,14 +34,15 @@ import org.apache.nifi.web.security.NiFiAuthenticationProvider;
 import org.apache.nifi.web.security.ProxiedEntitiesUtils;
 import org.apache.nifi.web.security.UntrustedProxyException;
 import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 
 import java.util.ArrayList;
-import java.util.HashMap;
+import java.util.Collections;
 import java.util.List;
 import java.util.ListIterator;
-import java.util.Map;
 import java.util.Set;
 
 /**
@@ -50,6 +50,8 @@ import java.util.Set;
  */
 public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
 
+    private static final Logger LOGGER = LoggerFactory.getLogger(X509AuthenticationProvider.class);
+
     private static final Authorizable PROXY_AUTHORIZABLE = new Authorizable() {
         @Override
         public Authorizable getParentAuthorizable() {
@@ -89,6 +91,9 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
             final String mappedIdentity = mapIdentity(authenticationResponse.getIdentity());
             return new NiFiAuthenticationToken(new NiFiUserDetails(new Builder().identity(mappedIdentity).groups(getUserGroups(mappedIdentity)).clientAddress(request.getClientAddress()).build()));
         } else {
+            // get the idp groups for the end-user that were sent over in the X-ProxiedEntityGroups header
+            final Set<String> endUserIdpGroups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups(request.getProxiedEntityGroups());
+
             // build the entire proxy chain if applicable - <end-user><proxy1><proxy2>
             final List<String> proxyChain = new ArrayList<>(ProxiedEntitiesUtils.tokenizeProxiedEntitiesChain(request.getProxiedEntitiesChain()));
             proxyChain.add(authenticationResponse.getIdentity());
@@ -111,11 +116,15 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
                     identity = mapIdentity(identity);
                 }
 
+                // get the groups from any configured UserGroupProviders
                 final Set<String> groups = getUserGroups(identity);
 
+                // only the end-user can have these groups so any other entity in the chain gets an empty set
+                final Set<String> idpGroups = chainIter.hasPrevious() ? Collections.emptySet() : endUserIdpGroups;
+
                 // Only set the client address for client making the request because we don't know the clientAddress of the proxied entities
                 String clientAddress = (proxy == null) ? request.getClientAddress() : null;
-                proxy = createUser(identity, groups, proxy, clientAddress, isAnonymous);
+                proxy = createUser(identity, groups, idpGroups, proxy, clientAddress, isAnonymous);
 
                 if (chainIter.hasPrevious()) {
                     try {
@@ -126,10 +135,28 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
                 }
             }
 
+            if (LOGGER.isTraceEnabled()) {
+                logProxyChain(proxy);
+            }
+
             return new NiFiAuthenticationToken(new NiFiUserDetails(proxy));
         }
     }
 
+    private void logProxyChain(final NiFiUser chain) {
+        final StringBuilder builder = new StringBuilder("\n== Proxy Entity Chain ==");
+        NiFiUser user = chain;
+        while (user != null) {
+            builder.append("\nIdentity: ")
+                    .append(user.getIdentity())
+                    .append(" , IDP Groups: ")
+                    .append(StringUtils.join(user.getIdentityProviderGroups()));
+            user = user.getChain();
+        }
+        builder.append("\n============");
+        LOGGER.trace(builder.toString());
+    }
+
     /**
      * Returns a regular user populated with the provided values, or if the user should be anonymous, a well-formed instance of the anonymous user with the provided values.
      *
@@ -139,23 +166,12 @@ public class X509AuthenticationProvider extends NiFiAuthenticationProvider {
      * @param isAnonymous   if true, an anonymous user will be returned (identity will be ignored)
      * @return the populated user
      */
-    protected static NiFiUser createUser(String identity, Set<String> groups, NiFiUser chain, String clientAddress, boolean isAnonymous) {
+    protected static NiFiUser createUser(String identity, Set<String> groups, Set<String> idpGroups, NiFiUser chain, String clientAddress, boolean isAnonymous) {
         if (isAnonymous) {
             return StandardNiFiUser.populateAnonymousUser(chain, clientAddress);
         } else {
-            return new Builder().identity(identity).groups(groups).chain(chain).clientAddress(clientAddress).build();
-        }
-    }
-
-    private Map<String, String> getUserContext(final X509AuthenticationRequestToken request) {
-        final Map<String, String> userContext;
-        if (!StringUtils.isBlank(request.getClientAddress())) {
-            userContext = new HashMap<>();
-            userContext.put(UserContextKeys.CLIENT_ADDRESS.name(), request.getClientAddress());
-        } else {
-            userContext = null;
+            return new Builder().identity(identity).groups(groups).identityProviderGroups(idpGroups).chain(chain).clientAddress(clientAddress).build();
         }
-        return userContext;
     }
 
     @Override
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationRequestToken.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationRequestToken.java
index 7b23653..857ea7b 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationRequestToken.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/java/org/apache/nifi/web/security/x509/X509AuthenticationRequestToken.java
@@ -28,6 +28,7 @@ import java.security.cert.X509Certificate;
 public class X509AuthenticationRequestToken extends NiFiAuthenticationRequestToken {
 
     private final String proxiedEntitiesChain;
+    private final String proxiedEntityGroups;
     private final X509PrincipalExtractor principalExtractor;
     private final X509Certificate[] certificates;
 
@@ -37,10 +38,13 @@ public class X509AuthenticationRequestToken extends NiFiAuthenticationRequestTok
      * @param proxiedEntitiesChain   The http servlet request
      * @param certificates  The certificate chain
      */
-    public X509AuthenticationRequestToken(final String proxiedEntitiesChain, final X509PrincipalExtractor principalExtractor, final X509Certificate[] certificates, final String clientAddress) {
+    public X509AuthenticationRequestToken(final String proxiedEntitiesChain, final String proxiedEntityGroups,
+                                          final X509PrincipalExtractor principalExtractor, final X509Certificate[] certificates,
+                                          final String clientAddress) {
         super(clientAddress);
         setAuthenticated(false);
         this.proxiedEntitiesChain = proxiedEntitiesChain;
+        this.proxiedEntityGroups = proxiedEntityGroups;
         this.principalExtractor = principalExtractor;
         this.certificates = certificates;
     }
@@ -63,6 +67,10 @@ public class X509AuthenticationRequestToken extends NiFiAuthenticationRequestTok
         return proxiedEntitiesChain;
     }
 
+    public String getProxiedEntityGroups() {
+        return proxiedEntityGroups;
+    }
+
     public X509Certificate[] getCertificates() {
         return certificates;
     }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/resources/nifi-web-security-context.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/resources/nifi-web-security-context.xml
index d37062a..dc205a2 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/resources/nifi-web-security-context.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/main/resources/nifi-web-security-context.xml
@@ -56,6 +56,7 @@
         <constructor-arg ref="jwtService" index="0"/>
         <constructor-arg ref="nifiProperties" index="1"/>
         <constructor-arg ref="authorizer" index="2"/>
+        <constructor-arg ref="idpUserGroupService" index="3"/>
     </bean>
 
     <!-- otp service -->
@@ -66,6 +67,7 @@
         <constructor-arg ref="otpService" index="0"/>
         <constructor-arg ref="nifiProperties" index="1"/>
         <constructor-arg ref="authorizer" index="2"/>
+        <constructor-arg ref="idpUserGroupService" index="3"/>
     </bean>
 
     <!-- knox service -->
@@ -100,6 +102,25 @@
         <constructor-arg ref="oidcProvider"/>
     </bean>
 
+    <!-- saml -->
+    <bean id="samlConfigurationFactory" class="org.apache.nifi.web.security.saml.impl.StandardSAMLConfigurationFactory" />
+
+    <bean id="samlService" class="org.apache.nifi.web.security.saml.impl.StandardSAMLService" init-method="initialize" destroy-method="shutdown">
+        <constructor-arg ref="samlConfigurationFactory" index="0"/>
+        <constructor-arg ref="nifiProperties" index="1"/>
+    </bean>
+
+    <bean id="samlStateManager" class="org.apache.nifi.web.security.saml.impl.StandardSAMLStateManager">
+        <constructor-arg ref="jwtService" index="0"/>
+    </bean>
+
+    <bean id="samlCredentialStore" class="org.apache.nifi.web.security.saml.impl.StandardSAMLCredentialStore">
+        <constructor-arg ref="idpCredentialService" index="0"/>
+    </bean>
+
+    <!-- logout -->
+    <bean id="logoutRequestManager" class="org.apache.nifi.web.security.logout.LogoutRequestManager"/>
+
     <!-- anonymous -->
     <bean id="anonymousAuthenticationProvider" class="org.apache.nifi.web.security.anonymous.NiFiAnonymousAuthenticationProvider">
         <constructor-arg ref="nifiProperties" index="0"/>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/TestProxiedEntitiesUtils.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/TestProxiedEntitiesUtils.java
new file mode 100644
index 0000000..0ca76ae
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/TestProxiedEntitiesUtils.java
@@ -0,0 +1,85 @@
+/*
+ * 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;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class TestProxiedEntitiesUtils {
+
+    @Test
+    public void testBuildProxiedEntityGroupsString() {
+        final Set<String> groups = new LinkedHashSet<>(Arrays.asList("group1", "group2", "group3"));
+        final String groupsString = ProxiedEntitiesUtils.buildProxiedEntityGroupsString(groups);
+        assertNotNull(groupsString);
+        assertEquals("<group1><group2><group3>", groupsString);
+    }
+
+    @Test
+    public void testBuildProxiedEntityGroupsStringWhenEmpty() {
+        final String groupsString = ProxiedEntitiesUtils.buildProxiedEntityGroupsString(Collections.emptySet());
+        assertNotNull(groupsString);
+        assertEquals(ProxiedEntitiesUtils.PROXY_ENTITY_GROUPS_EMPTY, groupsString);
+    }
+
+    @Test
+    public void testBuildProxiedEntityGroupsStringWithEscaping() {
+        final Set<String> groups = new LinkedHashSet<>(Arrays.asList("gro<up1", "gro>up2", "group3"));
+        final String groupsString = ProxiedEntitiesUtils.buildProxiedEntityGroupsString(groups);
+        assertNotNull(groupsString);
+        assertEquals("<gro\\<up1><gro\\>up2><group3>", groupsString);
+    }
+
+    @Test
+    public void testTokenizeProxiedEntityGroups() {
+        final Set<String> groups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups("<group1><group2><group3>");
+        assertEquals(3, groups.size());
+        assertTrue(groups.contains("group1"));
+        assertTrue(groups.contains("group2"));
+        assertTrue(groups.contains("group3"));
+    }
+
+    @Test
+    public void testTokenizeProxiedEntityGroupsWhenEscaped() {
+        final Set<String> groups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups("<gr\\<oup1><gro\\>up2><group3>");
+        assertEquals(3, groups.size());
+        assertTrue(groups.contains("gr<oup1"));
+        assertTrue(groups.contains("gro>up2"));
+        assertTrue(groups.contains("group3"));
+    }
+
+    @Test
+    public void testTokenizeProxiedEntityGroupsWhenEmpty() {
+        final Set<String> groups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups("<>");
+        assertEquals(0, groups.size());
+    }
+
+    @Test
+    public void testTokenizeProxiedEntityGroupsWhenNull() {
+        final Set<String> groups = ProxiedEntitiesUtils.tokenizeProxiedEntityGroups(null);
+        assertEquals(0, groups.size());
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProviderTest.java
index 8a1dbd4..e1232fa 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProviderTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtAuthenticationProviderTest.java
@@ -16,8 +16,18 @@
  */
 package org.apache.nifi.web.security.jwt;
 
+import org.apache.nifi.admin.service.IdpUserGroupService;
+import org.apache.nifi.authorization.AccessPolicyProvider;
 import org.apache.nifi.authorization.Authorizer;
+import org.apache.nifi.authorization.Group;
+import org.apache.nifi.authorization.ManagedAuthorizer;
+import org.apache.nifi.authorization.User;
+import org.apache.nifi.authorization.UserAndGroups;
+import org.apache.nifi.authorization.UserGroupProvider;
+import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.idp.IdpUserGroup;
 import org.apache.nifi.properties.StandardNiFiProperties;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.security.InvalidAuthenticationException;
@@ -28,10 +38,17 @@ import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ExpectedException;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Properties;
+import java.util.Set;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 public class JwtAuthenticationProviderTest {
 
@@ -54,12 +71,17 @@ public class JwtAuthenticationProviderTest {
 
 
     private JwtService jwtService;
+    private Authorizer authorizer;
+    private IdpUserGroupService idpUserGroupService;
+
     private JwtAuthenticationProvider jwtAuthenticationProvider;
 
     @Before
     public void setUp() throws Exception {
         TestKeyService keyService = new TestKeyService();
         jwtService = new JwtService(keyService);
+        idpUserGroupService = mock(IdpUserGroupService.class);
+        authorizer = mock(Authorizer.class);
 
         // Set up Kerberos identity mappings
         Properties props = new Properties();
@@ -67,7 +89,7 @@ public class JwtAuthenticationProviderTest {
         props.put(properties.SECURITY_IDENTITY_MAPPING_VALUE_PREFIX, "$1");
         properties = new StandardNiFiProperties(props);
 
-        jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtService, properties, mock(Authorizer.class));
+        jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtService, properties, authorizer, idpUserGroupService);
     }
 
     @Test
@@ -80,6 +102,8 @@ public class JwtAuthenticationProviderTest {
         String token = jwtService.generateSignedToken(loginAuthenticationToken);
         final JwtAuthenticationRequestToken request = new JwtAuthenticationRequestToken(token, CLIENT_ADDRESS);
 
+        when(idpUserGroupService.getUserGroups(ADMIN_IDENTITY)).thenReturn(Collections.emptyList());
+
         // Act
         final NiFiAuthenticationToken result = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(request);
         final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
@@ -98,6 +122,8 @@ public class JwtAuthenticationProviderTest {
         String token = jwtService.generateSignedToken(loginAuthenticationToken);
         final JwtAuthenticationRequestToken request = new JwtAuthenticationRequestToken(token, CLIENT_ADDRESS);
 
+        when(idpUserGroupService.getUserGroups(ADMIN_IDENTITY)).thenReturn(Collections.emptyList());
+
         // Act
         final NiFiAuthenticationToken result = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(request);
         final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
@@ -120,6 +146,8 @@ public class JwtAuthenticationProviderTest {
                         "MockIdentityProvider");
         jwtService.generateSignedToken(loginAuthenticationToken);
 
+        when(idpUserGroupService.getUserGroups(ADMIN_IDENTITY)).thenReturn(Collections.emptyList());
+
         // Act
         // Try to  authenticate with an unknown token
         final JwtAuthenticationRequestToken request = new JwtAuthenticationRequestToken(UNKNOWN_TOKEN, CLIENT_ADDRESS);
@@ -129,4 +157,127 @@ public class JwtAuthenticationProviderTest {
         // Expect exception
     }
 
+    @Test
+    public void testIdpUserGroupsPresent() {
+        // Arrange
+        LoginAuthenticationToken loginAuthenticationToken =
+                new LoginAuthenticationToken(ADMIN_IDENTITY,
+                        EXPIRATION_MILLIS,
+                        "MockIdentityProvider");
+        String token = jwtService.generateSignedToken(loginAuthenticationToken);
+        final JwtAuthenticationRequestToken request = new JwtAuthenticationRequestToken(token, CLIENT_ADDRESS);
+
+        final String groupName1 = "group1";
+        final IdpUserGroup idpUserGroup1 = createIdpUserGroup(1, ADMIN_IDENTITY, groupName1, IdpType.SAML);
+
+        final String groupName2 = "group2";
+        final IdpUserGroup idpUserGroup2 = createIdpUserGroup(2, ADMIN_IDENTITY, groupName2, IdpType.SAML);
+
+        when(idpUserGroupService.getUserGroups(ADMIN_IDENTITY)).thenReturn(Arrays.asList(idpUserGroup1, idpUserGroup2));
+
+        // Act
+        final NiFiAuthenticationToken result = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(request);
+        final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
+
+        // Assert details username is correct
+        assertEquals(ADMIN_IDENTITY, details.getUsername());
+
+        final NiFiUser returnedUser = details.getNiFiUser();
+        assertNotNull(returnedUser);
+
+        // Assert user-group-provider groups is empty
+        assertNull(returnedUser.getGroups());
+
+        // Assert identity-provider groups is correct
+        assertEquals(2, returnedUser.getIdentityProviderGroups().size());
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName1));
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName2));
+
+        // Assert combined groups has only idp groups
+        assertEquals(2, returnedUser.getAllGroups().size());
+        assertTrue(returnedUser.getAllGroups().contains(groupName1));
+        assertTrue(returnedUser.getAllGroups().contains(groupName2));
+    }
+
+    @Test
+    public void testCombineUserGroupProviderGroupsAndIdpUserGroups() {
+        // setup IdpUserGroupService...
+
+        final String groupName1 = "group1";
+        final IdpUserGroup idpUserGroup1 = createIdpUserGroup(1, ADMIN_IDENTITY, groupName1, IdpType.SAML);
+
+        final String groupName2 = "group2";
+        final IdpUserGroup idpUserGroup2 = createIdpUserGroup(2, ADMIN_IDENTITY, groupName2, IdpType.SAML);
+
+        idpUserGroupService = mock(IdpUserGroupService.class);
+        when(idpUserGroupService.getUserGroups(ADMIN_IDENTITY)).thenReturn(Arrays.asList(idpUserGroup1, idpUserGroup2));
+
+        // setup ManagedAuthorizer...
+        final String groupName3 = "group3";
+        final Group group3 = new Group.Builder().identifierGenerateRandom().name(groupName3).build();
+
+        final UserGroupProvider userGroupProvider = mock(UserGroupProvider.class);
+        when(userGroupProvider.getUserAndGroups(ADMIN_IDENTITY)).thenReturn(new UserAndGroups() {
+            @Override
+            public User getUser() {
+                return new User.Builder().identifier(ADMIN_IDENTITY).identity(ADMIN_IDENTITY).build();
+            }
+
+            @Override
+            public Set<Group> getGroups() {
+                return Collections.singleton(group3);
+            }
+        });
+
+        final AccessPolicyProvider accessPolicyProvider = mock(AccessPolicyProvider.class);
+        when(accessPolicyProvider.getUserGroupProvider()).thenReturn(userGroupProvider);
+
+        final ManagedAuthorizer managedAuthorizer = mock(ManagedAuthorizer.class);
+        when(managedAuthorizer.getAccessPolicyProvider()).thenReturn(accessPolicyProvider);
+
+        jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtService, properties, managedAuthorizer, idpUserGroupService);
+
+        // Arrange
+        LoginAuthenticationToken loginAuthenticationToken =
+                new LoginAuthenticationToken(ADMIN_IDENTITY,
+                        EXPIRATION_MILLIS,
+                        "MockIdentityProvider");
+        String token = jwtService.generateSignedToken(loginAuthenticationToken);
+        final JwtAuthenticationRequestToken request = new JwtAuthenticationRequestToken(token, CLIENT_ADDRESS);
+
+        // Act
+        final NiFiAuthenticationToken result = (NiFiAuthenticationToken) jwtAuthenticationProvider.authenticate(request);
+        final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
+
+        // Assert details username is correct
+        assertEquals(ADMIN_IDENTITY, details.getUsername());
+
+        final NiFiUser returnedUser = details.getNiFiUser();
+        assertNotNull(returnedUser);
+
+        // Assert user-group-provider groups are correct
+        assertEquals(1, returnedUser.getGroups().size());
+        assertTrue(returnedUser.getGroups().contains(groupName3));
+
+        // Assert identity-provider groups are correct
+        assertEquals(2, returnedUser.getIdentityProviderGroups().size());
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName1));
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName2));
+
+        // Assert combined groups are correct
+        assertEquals(3, returnedUser.getAllGroups().size());
+        assertTrue(returnedUser.getAllGroups().contains(groupName1));
+        assertTrue(returnedUser.getAllGroups().contains(groupName2));
+        assertTrue(returnedUser.getAllGroups().contains(groupName3));
+    }
+
+    private IdpUserGroup createIdpUserGroup(int id, String identity, String groupName, IdpType idpType) {
+        final IdpUserGroup userGroup = new IdpUserGroup();
+        userGroup.setId(id);
+        userGroup.setIdentity(identity);
+        userGroup.setGroupName(groupName);
+        userGroup.setType(idpType);
+        return userGroup;
+    }
+
 }
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
index 70f9a6d..eb1f5ee 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/jwt/JwtServiceTest.java
@@ -16,27 +16,7 @@
  */
 package org.apache.nifi.web.security.jwt;
 
-import static org.apache.nifi.util.NiFiProperties.SECURITY_IDENTITY_MAPPING_PATTERN_PREFIX;
-import static org.apache.nifi.util.NiFiProperties.SECURITY_IDENTITY_MAPPING_VALUE_PREFIX;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doAnswer;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
 import io.jsonwebtoken.JwtException;
-import java.nio.charset.StandardCharsets;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Properties;
-import javax.crypto.Mac;
-import javax.crypto.spec.SecretKeySpec;
 import org.apache.commons.codec.binary.Base64;
 import org.apache.nifi.admin.service.AdministrationException;
 import org.apache.nifi.admin.service.KeyService;
@@ -62,6 +42,28 @@ import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContext;
 import org.springframework.security.core.context.SecurityContextHolder;
 
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import static org.apache.nifi.util.NiFiProperties.SECURITY_IDENTITY_MAPPING_PATTERN_PREFIX;
+import static org.apache.nifi.util.NiFiProperties.SECURITY_IDENTITY_MAPPING_VALUE_PREFIX;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
 public class JwtServiceTest {
 
     private static final Logger logger = LoggerFactory.getLogger(JwtServiceTest.class);
@@ -453,6 +455,54 @@ public class JwtServiceTest {
         assertEquals("JWT token", EXPECTED_TOKEN_STRING, token);
     }
 
+    @Test
+    public void testShouldGenerateSignedTokenWithURLEncodedIssuer() throws Exception {
+        // Arrange
+
+        // Token expires in 60 seconds
+        final int EXPIRATION_MILLIS = 60000;
+        final String rawIssuer = "https://accounts.google.com/o/saml2?idpid=acode";
+        final LoginAuthenticationToken loginAuthenticationToken = new LoginAuthenticationToken("alopresto", EXPIRATION_MILLIS, rawIssuer);
+        logger.info("Generating token for " + loginAuthenticationToken);
+
+        final String EXPECTED_HEADER = DEFAULT_HEADER;
+
+        // Convert the expiration time from ms to s
+        final long TOKEN_EXPIRATION_SEC = (long) (loginAuthenticationToken.getExpiration() / 1000.0);
+
+        // Act
+        String token = jwtService.generateSignedToken(loginAuthenticationToken);
+        logger.info("Generated JWT: " + token);
+
+        // Run after the SUT generates the token to ensure the same issued at time
+        // Split the token, decode the middle section, and form a new String
+        final String DECODED_PAYLOAD = new String(Base64.decodeBase64(token.split("\\.")[1].getBytes()));
+        final long ISSUED_AT_SEC = Long.valueOf(DECODED_PAYLOAD.substring(DECODED_PAYLOAD.lastIndexOf(":") + 1,
+                DECODED_PAYLOAD.length() - 1));
+        logger.trace("Actual token was issued at " + ISSUED_AT_SEC);
+
+        // Always use LinkedHashMap to enforce order of the signingKeys because the signature depends on order
+        final String encodedIssuer = URLEncoder.encode(rawIssuer, "UTF-8");
+
+        final Map<String, Object> claims = new LinkedHashMap<>();
+        claims.put("sub", "alopresto");
+        claims.put("iss", encodedIssuer);
+        claims.put("aud", encodedIssuer);
+        claims.put("preferred_username", "alopresto");
+        claims.put("kid", 1);
+        claims.put("exp", TOKEN_EXPIRATION_SEC);
+        claims.put("iat", ISSUED_AT_SEC);
+        logger.trace("JSON Object to String: " + new JSONObject(claims).toString());
+
+        final String EXPECTED_PAYLOAD = new JSONObject(claims).toString();
+        final String EXPECTED_TOKEN_STRING = generateHS256Token(EXPECTED_HEADER, EXPECTED_PAYLOAD, true, true);
+        logger.info("Expected JWT: " + EXPECTED_TOKEN_STRING);
+
+        // Assert
+        assertEquals("JWT token", EXPECTED_TOKEN_STRING, token);
+    }
+
+
     @Test(expected = IllegalArgumentException.class)
     public void testShouldNotGenerateTokenWithNullAuthenticationToken() throws Exception {
         // Arrange
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/logout/TestLogoutRequestManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/logout/TestLogoutRequestManager.java
new file mode 100644
index 0000000..53a0d2c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/logout/TestLogoutRequestManager.java
@@ -0,0 +1,66 @@
+/*
+ * 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.logout;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public class TestLogoutRequestManager {
+
+    private LogoutRequestManager logoutRequestManager;
+
+    @Before
+    public void setup() {
+        logoutRequestManager = new LogoutRequestManager();
+    }
+
+    @Test
+    public void testLogoutSequence() {
+        final String logoutRequestId = "logoutRequest1";
+        final String userIdentity = "user1";
+
+        // create the request
+        final LogoutRequest logoutRequest = new LogoutRequest(logoutRequestId, userIdentity);
+        logoutRequestManager.start(logoutRequest);
+
+        // retrieve the request
+        final LogoutRequest retrievedRequest = logoutRequestManager.get(logoutRequestId);
+        assertNotNull(retrievedRequest);
+        assertEquals(logoutRequestId, retrievedRequest.getRequestIdentifier());
+        assertEquals(userIdentity, retrievedRequest.getMappedUserIdentity());
+
+        // complete the request
+        final LogoutRequest completedRequest = logoutRequestManager.complete(logoutRequestId);
+        assertNotNull(completedRequest);
+        assertEquals(logoutRequestId, completedRequest.getRequestIdentifier());
+        assertEquals(userIdentity, completedRequest.getMappedUserIdentity());
+
+        // verify request no long exists
+        final LogoutRequest shouldNotExistRequest = logoutRequestManager.get(logoutRequestId);
+        assertNull(shouldNotExistRequest);
+    }
+
+    @Test
+    public void testCompleteLogoutWhenDoesNotExist() {
+        final LogoutRequest shouldNotExistRequest = logoutRequestManager.complete("does-not-exist");
+        assertNull(shouldNotExistRequest);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/otp/OtpAuthenticationProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/otp/OtpAuthenticationProviderTest.java
index 3a3f40a..38c95a6 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/otp/OtpAuthenticationProviderTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/otp/OtpAuthenticationProviderTest.java
@@ -16,8 +16,18 @@
  */
 package org.apache.nifi.web.security.otp;
 
+import org.apache.nifi.admin.service.IdpUserGroupService;
+import org.apache.nifi.authorization.AccessPolicyProvider;
 import org.apache.nifi.authorization.Authorizer;
+import org.apache.nifi.authorization.Group;
+import org.apache.nifi.authorization.ManagedAuthorizer;
+import org.apache.nifi.authorization.User;
+import org.apache.nifi.authorization.UserAndGroups;
+import org.apache.nifi.authorization.UserGroupProvider;
+import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.authorization.user.NiFiUserDetails;
+import org.apache.nifi.idp.IdpType;
+import org.apache.nifi.idp.IdpUserGroup;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
 import org.junit.Before;
@@ -25,13 +35,21 @@ import org.junit.Test;
 import org.mockito.invocation.InvocationOnMock;
 import org.mockito.stubbing.Answer;
 
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class OtpAuthenticationProviderTest {
 
@@ -44,6 +62,7 @@ public class OtpAuthenticationProviderTest {
     private OtpService otpService;
     private OtpAuthenticationProvider otpAuthenticationProvider;
     private NiFiProperties nifiProperties;
+    private IdpUserGroupService idpUserGroupService;
 
     @Before
     public void setUp() throws Exception {
@@ -75,16 +94,30 @@ public class OtpAuthenticationProviderTest {
             }
         }).when(otpService).getAuthenticationFromUiExtensionToken(anyString());
 
-        otpAuthenticationProvider = new OtpAuthenticationProvider(otpService, mock(NiFiProperties.class), mock(Authorizer.class));
+        idpUserGroupService = mock(IdpUserGroupService.class);
+
+        otpAuthenticationProvider = new OtpAuthenticationProvider(
+                otpService, mock(NiFiProperties.class), mock(Authorizer.class), idpUserGroupService);
     }
 
     @Test
     public void testUiExtensionPath() throws Exception {
+        when(idpUserGroupService.getUserGroups(anyString())).thenReturn(Collections.emptyList());
+
         final OtpAuthenticationRequestToken request = new OtpAuthenticationRequestToken(UI_EXTENSION_TOKEN, false, null);
 
         final NiFiAuthenticationToken result = (NiFiAuthenticationToken) otpAuthenticationProvider.authenticate(request);
         final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
         assertEquals(UI_EXTENSION_AUTHENTICATED_USER, details.getUsername());
+        assertNotNull(details.getNiFiUser());
+
+        assertNull(details.getNiFiUser().getGroups());
+
+        assertNotNull(details.getNiFiUser().getIdentityProviderGroups());
+        assertEquals(0, details.getNiFiUser().getIdentityProviderGroups().size());
+
+        assertNotNull(details.getNiFiUser().getAllGroups());
+        assertEquals(0, details.getNiFiUser().getAllGroups().size());
 
         verify(otpService, times(1)).getAuthenticationFromUiExtensionToken(UI_EXTENSION_TOKEN);
         verify(otpService, never()).getAuthenticationFromDownloadToken(anyString());
@@ -92,14 +125,137 @@ public class OtpAuthenticationProviderTest {
 
     @Test
     public void testDownload() throws Exception {
+        when(idpUserGroupService.getUserGroups(anyString())).thenReturn(Collections.emptyList());
+
         final OtpAuthenticationRequestToken request = new OtpAuthenticationRequestToken(DOWNLOAD_TOKEN, true, null);
 
         final NiFiAuthenticationToken result = (NiFiAuthenticationToken) otpAuthenticationProvider.authenticate(request);
         final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
         assertEquals(DOWNLOAD_AUTHENTICATED_USER, details.getUsername());
+        assertNotNull(details.getNiFiUser());
+
+        assertNull(details.getNiFiUser().getGroups());
+
+        assertNotNull(details.getNiFiUser().getIdentityProviderGroups());
+        assertEquals(0, details.getNiFiUser().getIdentityProviderGroups().size());
+
+        assertNotNull(details.getNiFiUser().getAllGroups());
+        assertEquals(0, details.getNiFiUser().getAllGroups().size());
 
         verify(otpService, never()).getAuthenticationFromUiExtensionToken(anyString());
         verify(otpService, times(1)).getAuthenticationFromDownloadToken(DOWNLOAD_TOKEN);
     }
 
+    @Test
+    public void testWhenIdpUserGroupsArePresent() {
+        final String groupName1 = "group1";
+        final IdpUserGroup idpUserGroup1 = createIdpUserGroup(1, DOWNLOAD_AUTHENTICATED_USER, groupName1, IdpType.SAML);
+
+        final String groupName2 = "group2";
+        final IdpUserGroup idpUserGroup2 = createIdpUserGroup(2, DOWNLOAD_AUTHENTICATED_USER, groupName2, IdpType.SAML);
+
+        when(idpUserGroupService.getUserGroups(DOWNLOAD_AUTHENTICATED_USER)).thenReturn(Arrays.asList(idpUserGroup1, idpUserGroup2));
+
+        final OtpAuthenticationRequestToken request = new OtpAuthenticationRequestToken(DOWNLOAD_TOKEN, true, null);
+
+        final NiFiAuthenticationToken result = (NiFiAuthenticationToken) otpAuthenticationProvider.authenticate(request);
+        final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
+        assertEquals(DOWNLOAD_AUTHENTICATED_USER, details.getUsername());
+
+        final NiFiUser returnedUser = details.getNiFiUser();
+        assertNotNull(returnedUser);
+
+        // user-group-provider groups are null
+        assertNull(returnedUser.getGroups());
+
+        // identity-provider group contain the two groups
+        assertNotNull(returnedUser.getIdentityProviderGroups());
+        assertEquals(2, returnedUser.getIdentityProviderGroups().size());
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName1));
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName1));
+
+        verify(otpService, never()).getAuthenticationFromUiExtensionToken(anyString());
+        verify(otpService, times(1)).getAuthenticationFromDownloadToken(DOWNLOAD_TOKEN);
+    }
+
+    @Test
+    public void testWhenUserGroupProviderGroupsAndIdpUserGroupsArePresent() {
+        // setup idp user group service...
+
+        final String groupName1 = "group1";
+        final IdpUserGroup idpUserGroup1 = createIdpUserGroup(1, DOWNLOAD_AUTHENTICATED_USER, groupName1, IdpType.SAML);
+
+        final String groupName2 = "group2";
+        final IdpUserGroup idpUserGroup2 = createIdpUserGroup(2, DOWNLOAD_AUTHENTICATED_USER, groupName2, IdpType.SAML);
+
+        when(idpUserGroupService.getUserGroups(DOWNLOAD_AUTHENTICATED_USER)).thenReturn(Arrays.asList(idpUserGroup1, idpUserGroup2));
+
+        // setup managed authorizer...
+
+        final String groupName3 = "group3";
+        final Group group3 = new Group.Builder().identifierGenerateRandom().name(groupName3).build();
+
+        final UserGroupProvider userGroupProvider = mock(UserGroupProvider.class);
+        when(userGroupProvider.getUserAndGroups(DOWNLOAD_AUTHENTICATED_USER)).thenReturn(new UserAndGroups() {
+            @Override
+            public User getUser() {
+                return new User.Builder().identifier(DOWNLOAD_AUTHENTICATED_USER).identity(DOWNLOAD_AUTHENTICATED_USER).build();
+            }
+
+            @Override
+            public Set<Group> getGroups() {
+                return Collections.singleton(group3);
+            }
+        });
+
+        final AccessPolicyProvider accessPolicyProvider = mock(AccessPolicyProvider.class);
+        when(accessPolicyProvider.getUserGroupProvider()).thenReturn(userGroupProvider);
+
+        final ManagedAuthorizer managedAuthorizer = mock(ManagedAuthorizer.class);
+        when(managedAuthorizer.getAccessPolicyProvider()).thenReturn(accessPolicyProvider);
+
+        // create OTP auth provider using mocks from above
+
+        otpAuthenticationProvider = new OtpAuthenticationProvider(
+                otpService, mock(NiFiProperties.class), managedAuthorizer, idpUserGroupService);
+
+        // test...
+
+        final OtpAuthenticationRequestToken request = new OtpAuthenticationRequestToken(DOWNLOAD_TOKEN, true, null);
+
+        final NiFiAuthenticationToken result = (NiFiAuthenticationToken) otpAuthenticationProvider.authenticate(request);
+        final NiFiUserDetails details = (NiFiUserDetails) result.getPrincipal();
+        assertEquals(DOWNLOAD_AUTHENTICATED_USER, details.getUsername());
+
+        final NiFiUser returnedUser = details.getNiFiUser();
+        assertNotNull(returnedUser);
+
+        // Assert user-group-provider groups are correct
+        assertEquals(1, returnedUser.getGroups().size());
+        assertTrue(returnedUser.getGroups().contains(groupName3));
+
+        // Assert identity-provider groups are correct
+        assertEquals(2, returnedUser.getIdentityProviderGroups().size());
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName1));
+        assertTrue(returnedUser.getIdentityProviderGroups().contains(groupName2));
+
+        // Assert combined groups are correct
+        assertEquals(3, returnedUser.getAllGroups().size());
+        assertTrue(returnedUser.getAllGroups().contains(groupName1));
+        assertTrue(returnedUser.getAllGroups().contains(groupName2));
+        assertTrue(returnedUser.getAllGroups().contains(groupName3));
+
+        verify(otpService, never()).getAuthenticationFromUiExtensionToken(anyString());
+        verify(otpService, times(1)).getAuthenticationFromDownloadToken(DOWNLOAD_TOKEN);
+    }
+
+    private IdpUserGroup createIdpUserGroup(int id, String identity, String groupName, IdpType idpType) {
+        final IdpUserGroup userGroup = new IdpUserGroup();
+        userGroup.setId(id);
+        userGroup.setIdentity(identity);
+        userGroup.setGroupName(groupName);
+        userGroup.setType(idpType);
+        return userGroup;
+    }
+
 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLService.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLService.java
new file mode 100644
index 0000000..745bcec
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLService.java
@@ -0,0 +1,123 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.commons.lang3.SystemUtils;
+import org.apache.nifi.util.NiFiProperties;
+import org.apache.nifi.web.security.saml.SAMLConfigurationFactory;
+import org.apache.nifi.web.security.saml.SAMLService;
+import org.apache.nifi.web.security.saml.impl.tls.TruststoreStrategy;
+import org.junit.After;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.io.File;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class TestStandardSAMLService {
+
+    private NiFiProperties properties;
+    private SAMLConfigurationFactory samlConfigurationFactory;
+    private SAMLService samlService;
+
+
+    @BeforeClass
+    public static void setUpSuite() {
+        Assume.assumeTrue("Test only runs on *nix", !SystemUtils.IS_OS_WINDOWS);
+    }
+
+    @Before
+    public void setup() {
+        properties = mock(NiFiProperties.class);
+        samlConfigurationFactory = new StandardSAMLConfigurationFactory();
+        samlService = new StandardSAMLService(samlConfigurationFactory, properties);
+    }
+
+    @After
+    public void teardown() {
+        samlService.shutdown();
+    }
+
+    @Test
+    public void testSamlEnabledWithFileBasedIdpMetadata() {
+        final String spEntityId = "org:apache:nifi";
+        final File idpMetadataFile = new File("src/test/resources/saml/sso-circle-meta.xml");
+        final String baseUrl = "https://localhost:8443/nifi-api";
+
+        when(properties.getProperty(NiFiProperties.SECURITY_KEYSTORE)).thenReturn("src/test/resources/saml/keystore.jks");
+        when(properties.getProperty(NiFiProperties.SECURITY_KEYSTORE_PASSWD)).thenReturn("passwordpassword");
+        when(properties.getProperty(NiFiProperties.SECURITY_KEY_PASSWD)).thenReturn("passwordpassword");
+        when(properties.getProperty(NiFiProperties.SECURITY_KEYSTORE_TYPE)).thenReturn("JKS");
+        when(properties.getProperty(NiFiProperties.SECURITY_TRUSTSTORE)).thenReturn("src/test/resources/saml/truststore.jks");
+        when(properties.getProperty(NiFiProperties.SECURITY_TRUSTSTORE_PASSWD)).thenReturn("passwordpassword");
+        when(properties.getProperty(NiFiProperties.SECURITY_TRUSTSTORE_TYPE)).thenReturn("JKS");
+
+        when(properties.isSamlEnabled()).thenReturn(true);
+        when(properties.getSamlServiceProviderEntityId()).thenReturn(spEntityId);
+        when(properties.getSamlIdentityProviderMetadataUrl()).thenReturn("file://" + idpMetadataFile.getAbsolutePath());
+        when(properties.getSamlAuthenticationExpiration()).thenReturn("12 hours");
+        when(properties.getSamlHttpClientTruststoreStrategy()).thenReturn(TruststoreStrategy.JDK.name());
+
+        // initialize the saml service
+        samlService.initialize();
+        assertTrue(samlService.isSamlEnabled());
+
+        // initialize the service provider
+        assertFalse(samlService.isServiceProviderInitialized());
+        samlService.initializeServiceProvider(baseUrl);
+        assertTrue(samlService.isServiceProviderInitialized());
+
+        // obtain the service provider metadata xml
+        final String spMetadataXml = samlService.getServiceProviderMetadata();
+        assertTrue(spMetadataXml.contains("entityID=\"org:apache:nifi\""));
+        assertTrue(spMetadataXml.contains("<md:AssertionConsumerService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"https://localhost:8443/nifi-api/access/saml/login/consumer\""));
+        assertTrue(spMetadataXml.contains("<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"https://localhost:8443/nifi-api/access/saml/single-logout/consumer\"/>"));
+        assertTrue(spMetadataXml.contains("<md:SingleLogoutService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://localhost:8443/nifi-api/access/saml/single-logout/consumer\"/>"));
+    }
+
+    @Test
+    public void testInitializeWhenSamlNotEnabled() {
+        when(properties.isSamlEnabled()).thenReturn(false);
+
+        // initialize the saml service
+        samlService.initialize();
+        assertFalse(samlService.isSamlEnabled());
+
+        // methods should throw IllegalStateException...
+
+        try {
+            samlService.initializeServiceProvider("https://localhost:8443/nifi-api");
+            fail("Should have thrown exception");
+        } catch (IllegalStateException e) {
+
+        }
+
+        try {
+            samlService.getServiceProviderMetadata();
+            fail("Should have thrown exception");
+        } catch (IllegalStateException e) {
+
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLStateManager.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLStateManager.java
new file mode 100644
index 0000000..68fc146
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/saml/impl/TestStandardSAMLStateManager.java
@@ -0,0 +1,90 @@
+/*
+ * 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.saml.impl;
+
+import org.apache.nifi.web.security.jwt.JwtService;
+import org.apache.nifi.web.security.saml.SAMLStateManager;
+import org.apache.nifi.web.security.token.LoginAuthenticationToken;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.when;
+
+public class TestStandardSAMLStateManager {
+
+    private JwtService jwtService;
+    private SAMLStateManager stateManager;
+
+    @Before
+    public void setup() {
+        jwtService = mock(JwtService.class);
+        stateManager = new StandardSAMLStateManager(jwtService);
+    }
+
+    @Test
+    public void testCreateStateAndCheckIsValid() {
+        final String requestId = "request1";
+
+        // create state
+        final String state = stateManager.createState(requestId);
+        assertNotNull(state);
+
+        // should be valid
+        assertTrue(stateManager.isStateValid(requestId, state));
+
+        // should have been invalidated by checking if is valid above
+        assertFalse(stateManager.isStateValid(requestId, state));
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testCreateStateWhenExisting() {
+        final String requestId = "request1";
+        stateManager.createState(requestId);
+        stateManager.createState(requestId);
+    }
+
+    @Test
+    public void testIsValidWhenDoesNotExist() {
+        final String requestId = "request1";
+        assertFalse(stateManager.isStateValid(requestId, "some-state-value"));
+    }
+
+    @Test
+    public void testCreateAndGetJwt() {
+        final String requestId = "request1";
+        final LoginAuthenticationToken token = new LoginAuthenticationToken("user1", "user1", 10000, "nifi");
+
+        // create the jwt and cache it
+        final String fakeJwt = "fake-jwt";
+        when(jwtService.generateSignedToken(token)).thenReturn(fakeJwt);
+        stateManager.createJwt(requestId, token);
+
+        // should return the jwt above
+        final String jwt = stateManager.getJwt(requestId);
+        assertEquals(fakeJwt, jwt);
+
+        // should no longer exist after retrieving above
+        assertNull(stateManager.getJwt(requestId));
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationProviderTest.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationProviderTest.java
index e4a1c2a..83ba270 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationProviderTest.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/java/org/apache/nifi/web/security/x509/X509AuthenticationProviderTest.java
@@ -239,7 +239,7 @@ public class X509AuthenticationProviderTest {
         String identity = "someone";
 
         // Act
-        NiFiUser user = X509AuthenticationProvider.createUser(identity, null, null, null, true);
+        NiFiUser user = X509AuthenticationProvider.createUser(identity, null, null, null, null, true);
 
         // Assert
         assert user != null;
@@ -254,7 +254,7 @@ public class X509AuthenticationProviderTest {
         String identity = "someone";
 
         // Act
-        NiFiUser user = X509AuthenticationProvider.createUser(identity, null, null, null, false);
+        NiFiUser user = X509AuthenticationProvider.createUser(identity, null, null, null, null, false);
 
         // Assert
         assert user != null;
@@ -309,7 +309,11 @@ public class X509AuthenticationProviderTest {
     }
 
     private X509AuthenticationRequestToken getX509Request(final String proxyChain, final String identity) {
-        return new X509AuthenticationRequestToken(proxyChain, extractor, new X509Certificate[]{getX509Certificate(identity)}, "");
+        return getX509Request(proxyChain, null, identity);
+    }
+
+    private X509AuthenticationRequestToken getX509Request(final String proxyChain, final String proxiedEntityGroups, final String identity) {
+        return new X509AuthenticationRequestToken(proxyChain, proxiedEntityGroups, extractor, new X509Certificate[]{getX509Certificate(identity)}, "");
     }
 
     private X509Certificate getX509Certificate(final String identity) {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/keystore.jks b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/keystore.jks
new file mode 100644
index 0000000..34a197f
Binary files /dev/null and b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/keystore.jks differ
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/sso-circle-meta.xml b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/sso-circle-meta.xml
new file mode 100644
index 0000000..96ea864
--- /dev/null
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/sso-circle-meta.xml
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<EntityDescriptor entityID="https://idp.ssocircle.com" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
+    <IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+        <KeyDescriptor use="signing">
+            <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+                <ds:X509Data>
+                    <ds:X509Certificate>
+MIIEYzCCAkugAwIBAgIDIAZmMA0GCSqGSIb3DQEBCwUAMC4xCzAJBgNVBAYTAkRF
+MRIwEAYDVQQKDAlTU09DaXJjbGUxCzAJBgNVBAMMAkNBMB4XDTE2MDgwMzE1MDMy
+M1oXDTI2MDMwNDE1MDMyM1owPTELMAkGA1UEBhMCREUxEjAQBgNVBAoTCVNTT0Np
+cmNsZTEaMBgGA1UEAxMRaWRwLnNzb2NpcmNsZS5jb20wggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCAwWJyOYhYmWZF2TJvm1VyZccs3ZJ0TsNcoazr2pTW
+cY8WTRbIV9d06zYjngvWibyiylewGXcYONB106ZNUdNgrmFd5194Wsyx6bPvnjZE
+ERny9LOfuwQaqDYeKhI6c+veXApnOfsY26u9Lqb9sga9JnCkUGRaoVrAVM3yfghv
+/Cg/QEg+I6SVES75tKdcLDTt/FwmAYDEBV8l52bcMDNF+JWtAuetI9/dWCBe9VTC
+asAr2Fxw1ZYTAiqGI9sW4kWS2ApedbqsgH3qqMlPA7tg9iKy8Yw/deEn0qQIx8Gl
+VnQFpDgzG9k+jwBoebAYfGvMcO/BDXD2pbWTN+DvbURlAgMBAAGjezB5MAkGA1Ud
+EwQCMAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmlj
+YXRlMB0GA1UdDgQWBBQhAmCewE7aonAvyJfjImCRZDtccTAfBgNVHSMEGDAWgBTA
+1nEA+0za6ppLItkOX5yEp8cQaTANBgkqhkiG9w0BAQsFAAOCAgEAAhC5/WsF9ztJ
+Hgo+x9KV9bqVS0MmsgpG26yOAqFYwOSPmUuYmJmHgmKGjKrj1fdCINtzcBHFFBC1
+maGJ33lMk2bM2THx22/O93f4RFnFab7t23jRFcF0amQUOsDvltfJw7XCal8JdgPU
+g6TNC4Fy9XYv0OAHc3oDp3vl1Yj8/1qBg6Rc39kehmD5v8SKYmpE7yFKxDF1ol9D
+KDG/LvClSvnuVP0b4BWdBAA9aJSFtdNGgEvpEUqGkJ1osLVqCMvSYsUtHmapaX3h
+iM9RbX38jsSgsl44Rar5Ioc7KXOOZFGfEKyyUqucYpjWCOXJELAVAzp7XTvA2q55
+u31hO0w8Yx4uEQKlmxDuZmxpMz4EWARyjHSAuDKEW1RJvUr6+5uA9qeOKxLiKN1j
+o6eWAcl6Wr9MreXR9kFpS6kHllfdVSrJES4ST0uh1Jp4EYgmiyMmFCbUpKXifpsN
+WCLDenE3hllF0+q3wIdu+4P82RIM71n7qVgnDnK29wnLhHDat9rkC62CIbonpkVY
+mnReX0jze+7twRanJOMCJ+lFg16BDvBcG8u0n/wIDkHHitBI7bU1k6c6DydLQ+69
+h8SCo6sO9YuD+/3xAGKad4ImZ6vTwlB4zDCpu6YgQWocWRXE+VkOb+RBfvP755PU
+aLfL63AFVlpOnEpIio5++UjNJRuPuAA=
+                   </ds:X509Certificate>
+                </ds:X509Data>
+            </ds:KeyInfo>
+        </KeyDescriptor>
+        <KeyDescriptor use="encryption">
+            <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+                <ds:X509Data>
+                    <ds:X509Certificate>
+MIIEYzCCAkugAwIBAgIDIAZmMA0GCSqGSIb3DQEBCwUAMC4xCzAJBgNVBAYTAkRF
+MRIwEAYDVQQKDAlTU09DaXJjbGUxCzAJBgNVBAMMAkNBMB4XDTE2MDgwMzE1MDMy
+M1oXDTI2MDMwNDE1MDMyM1owPTELMAkGA1UEBhMCREUxEjAQBgNVBAoTCVNTT0Np
+cmNsZTEaMBgGA1UEAxMRaWRwLnNzb2NpcmNsZS5jb20wggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCAwWJyOYhYmWZF2TJvm1VyZccs3ZJ0TsNcoazr2pTW
+cY8WTRbIV9d06zYjngvWibyiylewGXcYONB106ZNUdNgrmFd5194Wsyx6bPvnjZE
+ERny9LOfuwQaqDYeKhI6c+veXApnOfsY26u9Lqb9sga9JnCkUGRaoVrAVM3yfghv
+/Cg/QEg+I6SVES75tKdcLDTt/FwmAYDEBV8l52bcMDNF+JWtAuetI9/dWCBe9VTC
+asAr2Fxw1ZYTAiqGI9sW4kWS2ApedbqsgH3qqMlPA7tg9iKy8Yw/deEn0qQIx8Gl
+VnQFpDgzG9k+jwBoebAYfGvMcO/BDXD2pbWTN+DvbURlAgMBAAGjezB5MAkGA1Ud
+EwQCMAAwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVkIENlcnRpZmlj
+YXRlMB0GA1UdDgQWBBQhAmCewE7aonAvyJfjImCRZDtccTAfBgNVHSMEGDAWgBTA
+1nEA+0za6ppLItkOX5yEp8cQaTANBgkqhkiG9w0BAQsFAAOCAgEAAhC5/WsF9ztJ
+Hgo+x9KV9bqVS0MmsgpG26yOAqFYwOSPmUuYmJmHgmKGjKrj1fdCINtzcBHFFBC1
+maGJ33lMk2bM2THx22/O93f4RFnFab7t23jRFcF0amQUOsDvltfJw7XCal8JdgPU
+g6TNC4Fy9XYv0OAHc3oDp3vl1Yj8/1qBg6Rc39kehmD5v8SKYmpE7yFKxDF1ol9D
+KDG/LvClSvnuVP0b4BWdBAA9aJSFtdNGgEvpEUqGkJ1osLVqCMvSYsUtHmapaX3h
+iM9RbX38jsSgsl44Rar5Ioc7KXOOZFGfEKyyUqucYpjWCOXJELAVAzp7XTvA2q55
+u31hO0w8Yx4uEQKlmxDuZmxpMz4EWARyjHSAuDKEW1RJvUr6+5uA9qeOKxLiKN1j
+o6eWAcl6Wr9MreXR9kFpS6kHllfdVSrJES4ST0uh1Jp4EYgmiyMmFCbUpKXifpsN
+WCLDenE3hllF0+q3wIdu+4P82RIM71n7qVgnDnK29wnLhHDat9rkC62CIbonpkVY
+mnReX0jze+7twRanJOMCJ+lFg16BDvBcG8u0n/wIDkHHitBI7bU1k6c6DydLQ+69
+h8SCo6sO9YuD+/3xAGKad4ImZ6vTwlB4zDCpu6YgQWocWRXE+VkOb+RBfvP755PU
+aLfL63AFVlpOnEpIio5++UjNJRuPuAA=
+                    </ds:X509Certificate>
+                </ds:X509Data>
+            </ds:KeyInfo>
+            <EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc">
+                <xenc:KeySize xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">128</xenc:KeySize>
+</EncryptionMethod>
+        </KeyDescriptor>
+        <ArtifactResolutionService index="0" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.ssocircle.com:443/sso/ArtifactResolver/metaAlias/publicidp"/>
+        <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.ssocircle.com:443/sso/IDPSloRedirect/metaAlias/publicidp" ResponseLocation="https://idp.ssocircle.com:443/sso/IDPSloRedirect/metaAlias/publicidp"/>
+        <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.ssocircle.com:443/sso/IDPSloPost/metaAlias/publicidp" ResponseLocation="https://idp.ssocircle.com:443/sso/IDPSloPost/metaAlias/publicidp"/>
+        <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.ssocircle.com:443/sso/IDPSloSoap/metaAlias/publicidp"/>
+        <ManageNameIDService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.ssocircle.com:443/sso/IDPMniRedirect/metaAlias/publicidp" ResponseLocation="https://idp.ssocircle.com:443/sso/IDPMniRedirect/metaAlias/publicidp"/>
+        <ManageNameIDService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.ssocircle.com:443/sso/IDPMniPOST/metaAlias/publicidp" ResponseLocation="https://idp.ssocircle.com:443/sso/IDPMniPOST/metaAlias/publicidp"/>
+        <ManageNameIDService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.ssocircle.com:443/sso/IDPMniSoap/metaAlias/publicidp"/>
+        <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
+        <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
+        <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</NameIDFormat>
+        <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
+        <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos</NameIDFormat>
+        <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp.ssocircle.com:443/sso/SSORedirect/metaAlias/publicidp"/>
+        <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp.ssocircle.com:443/sso/SSOPOST/metaAlias/publicidp"/>
+        <SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.ssocircle.com:443/sso/SSOSoap/metaAlias/publicidp"/>
+        <NameIDMappingService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://idp.ssocircle.com:443/sso/NIMSoap/metaAlias/publicidp"/>
+    </IDPSSODescriptor>
+</EntityDescriptor>
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/truststore.jks b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/truststore.jks
new file mode 100644
index 0000000..4bc1b20
Binary files /dev/null and b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-security/src/test/resources/saml/truststore.jks differ
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java
index c8025bd..2683724 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LoginFilter.java
@@ -41,6 +41,7 @@ public class LoginFilter implements Filter {
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
         final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
         final boolean supportsKnoxSso = Boolean.parseBoolean(servletContext.getInitParameter("knox-supported"));
+        final boolean supportsSAML = Boolean.parseBoolean(servletContext.getInitParameter("saml-supported"));
 
         if (supportsOidc) {
             final ServletContext apiContext = servletContext.getContext("/nifi-api");
@@ -48,6 +49,9 @@ public class LoginFilter implements Filter {
         } else if (supportsKnoxSso) {
             final ServletContext apiContext = servletContext.getContext("/nifi-api");
             apiContext.getRequestDispatcher("/access/knox/request").forward(request, response);
+        } else if (supportsSAML) {
+            final ServletContext apiContext = servletContext.getContext("/nifi-api");
+            apiContext.getRequestDispatcher("/access/saml/login/request").forward(request, response);
         } else {
             filterChain.doFilter(request, response);
         }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java
index 89e1821..e169594 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/java/org/apache/nifi/web/filter/LogoutFilter.java
@@ -16,7 +16,6 @@
  */
 package org.apache.nifi.web.filter;
 
-import java.io.IOException;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -24,7 +23,7 @@ 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 logout location.
@@ -42,6 +41,15 @@ public class LogoutFilter implements Filter {
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
         final boolean supportsOidc = Boolean.parseBoolean(servletContext.getInitParameter("oidc-supported"));
         final boolean supportsKnoxSso = Boolean.parseBoolean(servletContext.getInitParameter("knox-supported"));
+        final boolean supportsSaml = Boolean.parseBoolean(servletContext.getInitParameter("saml-supported"));
+        final boolean supportsSamlSingleLogout = Boolean.parseBoolean(servletContext.getInitParameter("saml-single-logout-supported"));
+
+        // NOTE: This filter runs in the web-ui module and is bound to /nifi/logout. Currently the front-end first makes an ajax call
+        // to issue a DELETE to /nifi-api/access/logout. After successful completion it sets the browser location to /nifi/logout
+        // which triggers this filter. Since this request was made from setting window.location, the JWT will never be sent which
+        // means there will be no logged in user or Authorization header when forwarding to any of the URLs below. Instead the
+        // /access/logout end-point sets a Cookie with a logout request identifier which can be used by the end-points below
+        // to retrieve information about the user logging out.
 
         if (supportsOidc) {
             final ServletContext apiContext = servletContext.getContext("/nifi-api");
@@ -49,8 +57,16 @@ public class LogoutFilter implements Filter {
         } else if (supportsKnoxSso) {
             final ServletContext apiContext = servletContext.getContext("/nifi-api");
             apiContext.getRequestDispatcher("/access/knox/logout").forward(request, response);
+        } else if (supportsSaml) {
+            final ServletContext apiContext = servletContext.getContext("/nifi-api");
+            if (supportsSamlSingleLogout) {
+                apiContext.getRequestDispatcher("/access/saml/single-logout/request").forward(request, response);
+            } else {
+                apiContext.getRequestDispatcher("/access/saml/local-logout").forward(request, response);
+            }
         } else {
-            ((HttpServletResponse) response).sendRedirect("logout-complete");
+            final ServletContext apiContext = servletContext.getContext("/nifi-api");
+            apiContext.getRequestDispatcher("/access/logout/complete").forward(request, response);
         }
     }
 
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js
index 22d6221..bb0e8d2 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/canvas/nf-canvas.js
@@ -108,6 +108,7 @@
             controllerBulletins: '../nifi-api/flow/controller/bulletins',
             kerberos: '../nifi-api/access/kerberos',
             oidc: '../nifi-api/access/oidc/exchange',
+            saml: '../nifi-api/access/saml/login/exchange',
             revision: '../nifi-api/flow/revision',
             banners: '../nifi-api/flow/banners'
         }
@@ -852,7 +853,7 @@
          * Initialize NiFi.
          */
         init: function () {
-            // attempt kerberos/oidc authentication
+            // attempt kerberos/oidc/saml authentication
             var ticketExchange = $.Deferred(function (deferred) {
                 var successfulAuthentication = function (jwt) {
                     // get the payload and store the token with the appropriate expiration
@@ -879,7 +880,15 @@
                         }).done(function (jwt) {
                             successfulAuthentication(jwt)
                         }).fail(function () {
-                            deferred.reject();
+                            $.ajax({
+                                type: 'POST',
+                                url: config.urls.saml,
+                                dataType: 'text'
+                            }).done(function (jwt) {
+                                successfulAuthentication(jwt)
+                            }).fail(function () {
+                                deferred.reject();
+                            });
                         });
                     });
                 }
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
index 63c4bf2..9d44b21 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-ui/src/main/webapp/js/nf/nf-common.js
@@ -98,8 +98,13 @@
 
         // handle logout
         $('#user-logout').on('click', function () {
-            nfStorage.removeItem('jwt');
-            window.location = '../nifi/logout';
+            $.ajax({
+                type: 'DELETE',
+                url: '../nifi-api/access/logout',
+            }).done(function () {
+                nfStorage.removeItem("jwt");
+                window.location = '../nifi/logout';
+            }).fail(nfErrorHandler.handleAjaxError);
         });
 
         // handle home
diff --git a/nifi-nar-bundles/nifi-framework-bundle/pom.xml b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
index d6b9fb8..9bf66a3 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/pom.xml
+++ b/nifi-nar-bundles/nifi-framework-bundle/pom.xml
@@ -522,6 +522,11 @@
                 </exclusions>
             </dependency>
             <dependency>
+                <groupId>org.springframework.security.extensions</groupId>
+                <artifactId>spring-security-saml2-core</artifactId>
+                <version>1.0.10.RELEASE</version>
+            </dependency>
+            <dependency>
                 <groupId>org.springframework.security.kerberos</groupId>
                 <artifactId>spring-security-kerberos-core</artifactId>
                 <version>1.0.1.RELEASE</version>
diff --git a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/LdapUserGroupProvider.java b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/LdapUserGroupProvider.java
index a542f94..5493abb 100644
--- a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/LdapUserGroupProvider.java
+++ b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/LdapUserGroupProvider.java
@@ -16,24 +16,6 @@
  */
 package org.apache.nifi.ldap.tenants;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ThreadFactory;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
-import javax.naming.Context;
-import javax.naming.NamingEnumeration;
-import javax.naming.NamingException;
-import javax.naming.directory.Attribute;
-import javax.naming.directory.SearchControls;
-import javax.net.ssl.SSLContext;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.authentication.exception.ProviderCreationException;
 import org.apache.nifi.authentication.exception.ProviderDestructionException;
@@ -78,6 +60,25 @@ import org.springframework.ldap.filter.AndFilter;
 import org.springframework.ldap.filter.EqualsFilter;
 import org.springframework.ldap.filter.HardcodedFilter;
 
+import javax.naming.Context;
+import javax.naming.NamingEnumeration;
+import javax.naming.NamingException;
+import javax.naming.directory.Attribute;
+import javax.naming.directory.SearchControls;
+import javax.net.ssl.SSLContext;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
 /**
  * Abstract LDAP based implementation of a login identity provider.
  */
@@ -437,6 +438,11 @@ public class LdapUserGroupProvider implements UserGroupProvider {
     }
 
     @Override
+    public Group getGroupByName(String name) throws AuthorizationAccessException {
+        return tenants.get().getGroupsByName().get(name);
+    }
+
+    @Override
     public UserAndGroups getUserAndGroups(String identity) throws AuthorizationAccessException {
         final TenantHolder holder = tenants.get();
         return new UserAndGroups() {
diff --git a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/TenantHolder.java b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/TenantHolder.java
index 2c0680e..20e92ab 100644
--- a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/TenantHolder.java
+++ b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/main/java/org/apache/nifi/ldap/tenants/TenantHolder.java
@@ -37,6 +37,7 @@ public class TenantHolder {
 
     private final Set<Group> allGroups;
     private final Map<String,Group> groupsById;
+    private final Map<String,Group> groupsByName;
     private final Map<String, Set<Group>> groupsByUserIdentity;
 
     /**
@@ -52,6 +53,9 @@ public class TenantHolder {
         // create a convenience map to retrieve a group by id
         final Map<String, Group> groupByIdMap = Collections.unmodifiableMap(createGroupByIdMap(allGroups));
 
+        // create a convenience map to retrieve a group by name
+        final Map<String, Group> groupByNameMap = Collections.unmodifiableMap(createGroupByNameMap(allGroups));
+
         // create a convenience map to retrieve the groups for a user identity
         final Map<String, Set<Group>> groupsByUserIdentityMap = Collections.unmodifiableMap(createGroupsByUserIdentityMap(allGroups, allUsers));
 
@@ -61,6 +65,7 @@ public class TenantHolder {
         this.usersById = userByIdMap;
         this.usersByIdentity = userByIdentityMap;
         this.groupsById = groupByIdMap;
+        this.groupsByName = groupByNameMap;
         this.groupsByUserIdentity = groupsByUserIdentityMap;
     }
 
@@ -107,6 +112,20 @@ public class TenantHolder {
     }
 
     /**
+     * Creates a Map from group name to Group.
+     *
+     * @param groups the set of all groups
+     * @return the Map from group name to Group
+     */
+    private Map<String,Group> createGroupByNameMap(final Set<Group> groups) {
+        Map<String,Group> groupsMap = new HashMap<>();
+        for (Group group : groups) {
+            groupsMap.put(group.getName(), group);
+        }
+        return groupsMap;
+    }
+
+    /**
      * Creates a Map from user identity to the set of Groups for that identity.
      *
      * @param groups all groups
@@ -148,6 +167,10 @@ public class TenantHolder {
         return groupsById;
     }
 
+    public Map<String, Group> getGroupsByName() {
+        return groupsByName;
+    }
+
     public User getUser(String identity) {
         if (identity == null) {
             throw new IllegalArgumentException("Identity cannot be null");
diff --git a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/test/java/org/apache/nifi/ldap/tenants/LdapUserGroupProviderTest.java b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/test/java/org/apache/nifi/ldap/tenants/LdapUserGroupProviderTest.java
index 393136f..5b9484b 100644
--- a/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/test/java/org/apache/nifi/ldap/tenants/LdapUserGroupProviderTest.java
+++ b/nifi-nar-bundles/nifi-ldap-iaa-providers-bundle/nifi-ldap-iaa-providers/src/test/java/org/apache/nifi/ldap/tenants/LdapUserGroupProviderTest.java
@@ -222,6 +222,22 @@ public class LdapUserGroupProviderTest extends AbstractLdapTestUnit {
     }
 
     @Test
+    public void testGetGroupByName() {
+        final AuthorizerConfigurationContext configurationContext = getBaseConfiguration(USER_SEARCH_BASE, null);
+        when(configurationContext.getProperty(PROP_USER_IDENTITY_ATTRIBUTE)).thenReturn(new StandardPropertyValue("uid", null, ParameterLookup.EMPTY));
+        when(configurationContext.getProperty(PROP_USER_GROUP_ATTRIBUTE)).thenReturn(new StandardPropertyValue("description", null, ParameterLookup.EMPTY)); // using description in lieu of memberof
+        when(configurationContext.getProperty(PROP_GROUP_NAME_ATTRIBUTE)).thenReturn(new StandardPropertyValue("cn", null, ParameterLookup.EMPTY));
+        ldapUserGroupProvider.onConfigured(configurationContext);
+
+        assertEquals(8, ldapUserGroupProvider.getUsers().size());
+        assertEquals(2, ldapUserGroupProvider.getGroups().size());
+
+        final Group group = ldapUserGroupProvider.getGroupByName("team1");
+        assertNotNull(group);
+        assertEquals("team1", group.getName());
+    }
+
+    @Test
     public void testSearchUsersWithGroupingAndGroupName() throws Exception {
         final AuthorizerConfigurationContext configurationContext = getBaseConfiguration(USER_SEARCH_BASE, null);
         when(configurationContext.getProperty(PROP_USER_IDENTITY_ATTRIBUTE)).thenReturn(new StandardPropertyValue("uid", null, ParameterLookup.EMPTY));
diff --git a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/ITestPersistentProvenanceRepository.java b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/ITestPersistentProvenanceRepository.java
index cf1256c..821087d 100644
--- a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/ITestPersistentProvenanceRepository.java
+++ b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/ITestPersistentProvenanceRepository.java
@@ -2112,6 +2112,16 @@ public class ITestPersistentProvenanceRepository {
             }
 
             @Override
+            public Set<String> getIdentityProviderGroups() {
+                return Collections.EMPTY_SET;
+            }
+
+            @Override
+            public Set<String> getAllGroups() {
+                return Collections.EMPTY_SET;
+            }
+
+            @Override
             public NiFiUser getChain() {
                 return null;
             }
diff --git a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/index/lucene/TestLuceneEventIndex.java b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/index/lucene/TestLuceneEventIndex.java
index 959f71b..0547723 100644
--- a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/index/lucene/TestLuceneEventIndex.java
+++ b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-persistent-provenance-repository/src/test/java/org/apache/nifi/provenance/index/lucene/TestLuceneEventIndex.java
@@ -385,6 +385,16 @@ public class TestLuceneEventIndex {
             }
 
             @Override
+            public Set<String> getIdentityProviderGroups() {
+                return Collections.emptySet();
+            }
+
+            @Override
+            public Set<String> getAllGroups() {
+                return Collections.emptySet();
+            }
+
+            @Override
             public NiFiUser getChain() {
                 return null;
             }
diff --git a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-volatile-provenance-repository/src/test/java/org/apache/nifi/provenance/TestVolatileProvenanceRepository.java b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-volatile-provenance-repository/src/test/java/org/apache/nifi/provenance/TestVolatileProvenanceRepository.java
index dfbe15b..1bfe4a9 100644
--- a/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-volatile-provenance-repository/src/test/java/org/apache/nifi/provenance/TestVolatileProvenanceRepository.java
+++ b/nifi-nar-bundles/nifi-provenance-repository-bundle/nifi-volatile-provenance-repository/src/test/java/org/apache/nifi/provenance/TestVolatileProvenanceRepository.java
@@ -16,15 +16,6 @@
  */
 package org.apache.nifi.provenance;
 
-import static org.junit.Assert.assertEquals;
-
-import java.io.IOException;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.UUID;
 import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.flowfile.FlowFile;
 import org.apache.nifi.provenance.search.Query;
@@ -34,6 +25,16 @@ import org.apache.nifi.util.NiFiProperties;
 import org.junit.BeforeClass;
 import org.junit.Test;
 
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.Assert.assertEquals;
+
 public class TestVolatileProvenanceRepository {
 
     private VolatileProvenanceRepository repo;
@@ -192,6 +193,16 @@ public class TestVolatileProvenanceRepository {
             }
 
             @Override
+            public Set<String> getIdentityProviderGroups() {
+                return Collections.EMPTY_SET;
+            }
+
+            @Override
+            public Set<String> getAllGroups() {
+                return Collections.EMPTY_SET;
+            }
+
+            @Override
             public NiFiUser getChain() {
                 return null;
             }
diff --git a/pom.xml b/pom.xml
index 516f0ec..a6d00f3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -139,6 +139,11 @@
                 <enabled>true</enabled>
             </releases>
         </repository>
+        <repository>
+            <id>Shibboleth</id>
+            <name>Shibboleth</name>
+            <url>https://build.shibboleth.net/nexus/content/repositories/releases/</url>
+        </repository>
     </repositories>
 
     <pluginRepositories>