[java-oidfed-common] 03/03: Import and refactor entity-configuration flow from java-idp-plugin-oidc-op-oidfed

Codeberg noreply at shibboleth.net
Fri May 15 07:49:31 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch main
in repository java-oidfed-common.

View the commit online:
https://codeberg.org/Shibboleth/java-oidfed-common/commit/009ab5b866301de7563447b57767680844b61cb3

commit 009ab5b866301de7563447b57767680844b61cb3
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Fri May 15 10:48:56 2026 +0300

    Import and refactor entity-configuration flow from java-idp-plugin-oidc-op-oidfed
    
    - New EntityConfigurationMetadataDecorator for providing plugins a method for extending entity configuration metadata
      - Implementing beans are autowired to EntityConfigurationMetadataDecoratorManager
    - Moved required profile actions and functions to net.shibboleth.idp.plugin.oidc.op.oidfed.profile impl/navigate
---
 .gitignore                                         |   3 +-
 .../EntityConfigurationMetadataDecorator.java      |  33 +
 ...ntityConfigurationMetadataDecoratorManager.java |  73 ++
 .../oidfed/profile/OidFederationEventIds.java      |  84 +++
 .../entity-configuration-beans.xml                 | 280 +++++++
 .../entity-configuration-flow.xml                  | 121 +++
 .../DefaultTrustChainFetchingStrategyTest.java     | 365 +++++++++
 .../flow/oidfed/AbstractFederationFlowTest.java    | 839 +++++++++++++++++++++
 .../flow/oidfed/EntityConfigurationFlowTest.java   | 160 ++++
 .../EntityConfigurationMetadataCacheTest.java      | 594 +++++++++++++++
 .../cache/SignedKeysetMetadataCacheTest.java       | 233 ++++++
 .../SubordinateStatementMetadataCacheTest.java     | 665 ++++++++++++++++
 .../oidfed/cache/TrustChainMetadataCacheTest.java  | 274 +++++++
 .../CustomEntityConfigurationFilterStrategy.java   |  58 ++
 ...CustomEntityConfigurationMetadataDecorator.java |  46 ++
 .../CustomSubordinateStatementFilterStrategy.java  |  58 ++
 .../support/CustomTrustChainFilterStrategy.java    |  47 ++
 .../shibboleth/oidfed/test/TrustChainTestUtil.java | 164 ++++
 .../resources/credentials/fed-local-anchor.jwk     |  13 +
 .../credentials/fed-local-intermediate.jwk         |  12 +
 .../resources/credentials/fed-signing-es256.jwk    |  10 +
 .../resources/credentials/fed-signing-es384.jwk    |   9 +
 .../resources/credentials/fed-signing-es521.jwk    |   9 +
 .../test/resources/credentials/fed-signing-rs.jwk  |   8 +
 .../net/shibboleth/idp/module/conf/credentials.xml |  70 ++
 .../net/shibboleth/idp/module/conf/global.xml      |  53 ++
 .../net/shibboleth/idp/module/conf/idp.properties  | 210 ++++++
 .../net/shibboleth/idp/module/conf/logback.xml     | 197 +++++
 .../idp/module/conf/metadata-providers.xml         |  28 +
 .../idp/module/conf/oidfed/oidfed-credentials.xml  |  37 +
 .../oidfed/oidfed-entity-configuration-claims.xml  |  44 ++
 .../oidfed-entity-configuration-metadata.json      |  11 +
 .../module/conf/oidfed/oidfed-trust-anchors.json   |  24 +
 .../conf/oidfed/oidfed-trustchain-resolver.xml     |  25 +
 .../idp/module/conf/oidfed/oidfed.properties       |  13 +
 .../shibboleth/idp/module/conf/relying-party.xml   |  33 +
 .../net/shibboleth/idp/module/conf/services.xml    |  92 +++
 .../impl/AbstractBuildEntityStatementAction.java   | 301 ++++++++
 .../profile/impl/BuildEntityConfiguration.java     | 215 ++++++
 ...TrustMarkFromMetadataCacheFetchingFunction.java | 205 +++++
 .../profile/impl/EntityStatementContext.java       | 133 ++++
 .../impl/EntityStatementUpdateStrategy.java        |  57 ++
 ...ormOutboundFederationConfigurationResponse.java | 214 ++++++
 .../impl/InitializeEntityStatementContext.java     | 165 ++++
 ...ClaimsSetFromEntityStatementLookupFunction.java |  85 +++
 .../profile/impl/LookupCachedNimbusResponse.java   | 141 ++++
 .../impl/RelyingPartyCachedMessageContext.java     |  75 ++
 ...ntityConfigurationTrustMarksLookupStrategy.java |  77 ++
 .../DefaultTrustAnchorHintsLookupStrategy.java     |  89 +++
 49 files changed, 6751 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index b1d018d..2d96300 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,4 +15,5 @@
 /oidfed-common-api/target
 /oidfed-common-impl/target
 /oidfed-common-dist/target
-
+/oidfed-common-conf-impl/src/test/resources/conf/local-log-config.properties
+/oidfed-common-conf-impl/classpath:
diff --git a/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecorator.java b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecorator.java
new file mode 100644
index 0000000..aad941f
--- /dev/null
+++ b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecorator.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile;
+
+import java.util.Map;
+import java.util.function.BiConsumer;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+
+/**
+ * An interface for decorating entity configuration metadata.
+ * 
+ * The interface extends the {@code BiConsumer} by specifying the input types explicitly and is expected to operate via
+ * side-effects.
+ */
+public interface EntityConfigurationMetadataDecorator extends
+    BiConsumer<Map<String,Map<String,Object>>, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecoratorManager.java b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecoratorManager.java
new file mode 100644
index 0000000..4052100
--- /dev/null
+++ b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/EntityConfigurationMetadataDecoratorManager.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile;
+
+import java.util.Collection;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.springframework.beans.factory.annotation.Autowired;
+
+import net.shibboleth.shared.annotation.constraint.NotLive;
+import net.shibboleth.shared.annotation.constraint.Unmodifiable;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * Manages and exposes instances of the {@link EntityConfigurationMetadataDecorator} interface.
+ */
+public class EntityConfigurationMetadataDecoratorManager {
+
+    /** Collection of decorators. */
+    @Nonnull private final Collection<EntityConfigurationMetadataDecorator> metadataDecorators;
+    
+    /**
+     * Constructor.
+     *
+     * @param decorators instances to manage
+     */
+    @Autowired
+    public EntityConfigurationMetadataDecoratorManager(
+            @Nullable final Collection<EntityConfigurationMetadataDecorator> decorators) {
+        metadataDecorators =
+                decorators == null ? CollectionSupport.emptyList() : CollectionSupport.copyToList(decorators);
+    }
+
+    /**
+     * Get all of the registered decorators.
+     * 
+     * @return all registered decorators
+     */
+    @Nonnull @NotLive @Unmodifiable public Collection<EntityConfigurationMetadataDecorator> all() {
+        return CollectionSupport.copyToList(metadataDecorators);
+    }
+
+    /**
+     * Get a {@link EntityConfigurationMetadataDecorator} by type.
+     * 
+     * @param <T> class type
+     * @param claz class type
+     * 
+     * @return decorator for the type, or null
+     */
+    @Nullable public <T extends EntityConfigurationMetadataDecorator> List<T> byClass(@Nonnull final Class<T> claz) {
+        return metadataDecorators.stream()
+                .filter(claz::isInstance)
+                .map(claz::cast)
+                .toList();
+    }
+
+}
diff --git a/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/OidFederationEventIds.java b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/OidFederationEventIds.java
new file mode 100644
index 0000000..2e1bd20
--- /dev/null
+++ b/oidfed-common-api/src/main/java/net/shibboleth/oidfed/profile/OidFederationEventIds.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * OpenID Federation -specific constants to use for {@link org.opensaml.profile.action.ProfileAction}
+ * {@link org.opensaml.profile.context.EventContext}s.
+ */
+public class OidFederationEventIds {
+
+    /**
+     * ID of event returned if a flow wishes to indicate that another trust chain should be selected instead
+     */
+    @Nonnull @NotEmpty public static final String RESELECT_TRUST_CHAIN = "ReselectTrustChain";
+
+    /**
+     * ID of event returned if cached response was found and set to the context.
+     */
+    @Nonnull @NotEmpty public static final String CACHED_RESPONSE_FOUND = "CachedResponseFound";
+
+    /**
+     * ID of event returned if no trust chains were resolved for the client.
+     */
+    @Nonnull @NotEmpty public static final String NO_TRUST_CHAINS_RESOLVED = "NoTrustChainsResolved";
+
+    /**
+     * ID of event returned if the given trust anchor is invalid.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_TRUST_ANCHOR = "InvalidTrustAnchor";
+
+    /**
+     * ID of event returned if the given subject is invalid.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_SUBJECT = "InvalidSubject";
+
+    /**
+     * ID of event returned if the given metadata is invalid.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_METADATA = "InvalidMetadata";
+
+    /**
+     * ID of event returned if the given metadata policy is invalid.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_METADATA_POLICY = "InvalidMetadataPolicy";
+
+    /**
+     * ID of event returned if the given metadata is invalid against policy.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_METADATA_AGAINST_POLICY = "InvalidMetadataAgainstPolicy";
+
+    /**
+     * ID of event returned if the trust chain is invalid against constraints.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_TRUST_CHAIN_AGAINST_CONSTRAINTS =
+            "InvalidTrustChainAgainstConstraints";
+
+    /**
+     * ID of event returned if the mandatory provided trust chain could not be fetched.
+     */
+    @Nonnull @NotEmpty public static final String MISSING_MANDATORY_PROVIDED_TRUST_CHAIN =
+            "MissingMandatoryProvidedTrustChain";
+
+    /**
+     * ID of event returned if the provided trust chain could not be verified.
+     */
+    @Nonnull @NotEmpty public static final String INVALID_PROVIDED_TRUST_CHAIN = "InvalidProvidedTrustChain";
+
+}
diff --git a/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-beans.xml b/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-beans.xml
new file mode 100644
index 0000000..5f02e7d
--- /dev/null
+++ b/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-beans.xml
@@ -0,0 +1,280 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans" xmlns:c="http://www.springframework.org/schema/c"
+    xmlns:context="http://www.springframework.org/schema/context" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <bean id="shibboleth.oidfed.profileId" class="java.lang.String"
+        c:_0="#{T(net.shibboleth.oidfed.profile.config.OIDFederationEntityConfigurationProfileConfiguration).PROFILE_ID}" />
+
+    <bean id="shibboleth.oidfed.loggingId" class="java.lang.String" c:_0="%{idp.service.logging.oidfedconfig:OIDFED.Configuration}" />
+
+    <bean id="shibboleth.oidfed.browserProfile" class="java.lang.Boolean" c:_0="false" />
+
+    <util:constant id="shibboleth.metrics.ProfileCounter"
+        static-field="net.shibboleth.oidfed.profile.config.impl.DefaultOIDFederationEntityConfigurationProfileConfiguration.PROFILE_COUNTER" />
+
+    <bean id="shibboleth.oidfed.EntityConfigurationResponseMetadataCache" parent="shibboleth.oidfed.CacheBuilder">
+        <constructor-arg>
+            <bean p:cacheId="DefaultEntityConfigurationResponseMetadataCache" parent="shibboleth.oidfed.EntityConfigurationResponseMetadataCacheBuilderSpec"
+                p:cleanupTaskInterval="PT30S"/>
+        </constructor-arg>
+    </bean>
+
+    <bean id="shibboleth.oidfed.EntityConfigurationResponseMetadataCacheBuilderSpec"
+        class="net.shibboleth.oidc.metadata.cache.impl.DynamicMetadataCacheBuilderSpec"
+        p:minCacheDuration="%{idp.oidfed.entity-configuration.maxRefreshDelay:PT1S}"
+        p:maxCacheDuration="%{idp.oidfed.entity-configuration.maxRefreshDelay:PT30S}">
+        <property name="criteriaToIdentifierStrategy">
+            <bean parent="shibboleth.Functions.Constant" c:target-ref="shibboleth.oidfed.entityId" />
+        </property>
+        <property name="identifierExtractionStrategy">
+            <bean parent="shibboleth.Functions.Constant" c:target-ref="shibboleth.oidfed.entityId" />
+        </property>
+        <property name="metadataExpirationTimeStrategy">
+            <bean class="net.shibboleth.oidfed.metadata.cache.local.DefaultNimbusResponseContainerExpirationTimeStrategy"/>
+        </property>
+        <property name="metadataFilterStrategy">
+            <bean parent="shibboleth.BiFunctions.Expression" c:expression="#input1"/>
+        </property>
+        <property name="fetchStrategy">
+            <bean class="net.shibboleth.oidfed.metadata.cache.local.DefaultEntityConfigurationResponseFetchingStrategy" />
+        </property>
+    </bean>
+
+    <bean id="InitializeProfileRequestContext"
+        class="net.shibboleth.idp.profile.impl.InitializeProfileRequestContext" scope="prototype"
+        p:profileId-ref="shibboleth.oidfed.profileId"
+        p:loggingId-ref="shibboleth.oidfed.loggingId"
+        p:browserProfile-ref="shibboleth.oidfed.browserProfile" />
+
+    <bean id="PopulateMetricContext"
+        class="org.opensaml.profile.action.impl.PopulateMetricContext" scope="prototype"
+        p:counterName="#{getObject('shibboleth.metrics.ProfileCounter')}"
+        p:metricStrategy="#{getObject('shibboleth.metrics.MetricStrategy')}" />
+
+    <bean id="SelectRelyingPartyConfiguration"
+        class="net.shibboleth.idp.profile.impl.SelectRelyingPartyConfiguration" scope="prototype"
+        p:relyingPartyConfigurationResolver-ref="shibboleth.RelyingPartyResolverService" />
+
+    <bean id="SelectProfileConfiguration" class="net.shibboleth.idp.profile.impl.SelectProfileConfiguration"
+        scope="prototype" />
+
+    <bean id="CallInboundMessageHandler"
+        class="net.shibboleth.idp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
+        c:executionDirection="INBOUND">
+        <constructor-arg name="messageHandler">
+            <bean class="org.opensaml.messaging.handler.impl.FunctionMessageHandler" scope="prototype">
+                <property name="functionLookupStrategy">
+                    <bean class="net.shibboleth.oidc.profile.config.navigate.MessageHandlerLookupFunction" />
+                </property>
+            </bean>
+        </constructor-arg>
+        <property name="errorEvent">
+            <util:constant static-field="org.opensaml.profile.action.EventIds.MESSAGE_PROC_ERROR" />
+        </property>
+    </bean>
+
+    <bean id="PopulateInboundInterceptContext"
+            class="net.shibboleth.idp.profile.interceptor.impl.PopulateProfileInterceptorContext" scope="prototype"
+            p:availableFlows="#{@'shibboleth.ProfileInterceptorFlowDescriptorManager'.getComponents()}"
+            p:loggingLabel="inbound">
+        <property name="activeFlowsLookupStrategy">
+            <bean class="net.shibboleth.idp.profile.config.navigate.InboundFlowsLookupFunction" />
+        </property>
+    </bean>
+
+    <bean id="LogEvent" class="org.opensaml.profile.action.impl.LogEvent" scope="prototype"
+        p:suppressedEvents="#{getObject('shibboleth.SuppressedEvents') ?: getObject('shibboleth.DefaultSuppressedEvents')}">
+        <property name="eventContextLookupStrategy">
+            <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+        </property>
+    </bean>
+
+    <bean id="LookupCachedResponse"
+        class="net.shibboleth.oidfed.profile.impl.LookupCachedNimbusResponse"
+        scope="prototype"
+        p:responseCache-ref="shibboleth.oidfed.EntityConfigurationResponseMetadataCache" />
+
+    <bean id="InitializeEntityStatementContext"
+        class="net.shibboleth.oidfed.profile.impl.InitializeEntityStatementContext"
+        p:metadataSkeletonLookupStrategy-ref="#{'%{idp.oidfed.configuration.EntityConfigurationMetadataSkeletonLookupStrategy:DefaultEntityConfigurationMetadataSkeletonLookupStrategy}'.trim()}"
+        p:metadataDecoratorManager-ref="#{'%{idp.oidfed.configuration.MetadataDecoratorManager:shibboleth.oidfed.DefaultMetadataDecoratorManager}'.trim()}"/>
+
+    <bean id="shibboleth.oidfed.DefaultMetadataDecoratorManager"
+        class="net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecoratorManager"/>
+
+    <bean id="DefaultEntityConfigurationMetadataSkeletonLookupStrategy"
+        class="net.shibboleth.oidfed.metadata.cache.local.DefaultEntityConfigurationMetadataSkeletonLookupStrategy"
+        p:metadataSkeletonCache-ref="shibboleth.oidfed.EntityConfigurationMetadataSkeletonMetadataCache"/>
+
+    <bean id="PopulateEntityStatementSignatureSigningParameters"
+        class="net.shibboleth.oidc.profile.impl.PopulateJWTSignatureSigningParameters" scope="prototype"
+        c:strategy-ref="shibboleth.MessageContextLookup.Outbound"
+        p:securityParametersContextLookupStrategy-ref="EntityStatementSecurityParametersContextLookupStrategy">
+        <property name="configurationLookupStrategy">
+            <bean lazy-init="true"
+                class="net.shibboleth.oidc.profile.config.navigate.JWTSignatureSigningConfigurationLookupFunction" />
+        </property>
+        <property name="signatureSigningParametersResolver">
+            <bean class="net.shibboleth.oidc.security.jose.impl.ClientInformationSignatureSigningParametersResolver">
+                <constructor-arg name="signatureAlgorithmLookupStrategy">
+                    <bean parent="shibboleth.Functions.Constant" c:target="" />
+                </constructor-arg>
+                <constructor-arg name="defaultAlgorithmValue" value="%{idp.oidfed.entity.sigalg:RS256}" />
+            </bean>
+        </property>
+    </bean>
+
+    <bean id="EntityStatementSecurityParametersContextLookupStrategy" parent="shibboleth.Functions.Compose"
+            c:g-ref="shibboleth.oidfed.ChildLookupOrCreate.JWTSecurityParameters"
+            c:f-ref="shibboleth.ChildLookup.RelyingParty" />
+
+    <bean id="shibboleth.oidfed.ChildLookupOrCreate.JWTSecurityParameters"
+        class="org.opensaml.messaging.context.navigate.ChildContextLookup"
+        c:type="#{ T(net.shibboleth.oidc.security.jose.context.SecurityParametersContext) }"
+        c:createContext="true" />
+
+    <bean id="EntityStatementSecurityParametersCreationViaMessageContextStrategy" parent="shibboleth.Functions.Compose">
+        <constructor-arg name="g" ref="EntityStatementSecurityParametersContextLookupStrategy" />
+        <constructor-arg name="f">
+            <bean parent="shibboleth.Functions.Expression" c:expression="#input.getParent()" />
+        </constructor-arg>
+    </bean>
+
+    <bean id="ConfigurationRelyingPartyCreationStrategy" parent="shibboleth.Functions.Compose"
+        c:g-ref="shibboleth.ChildLookupOrCreate.RelyingPartyContext"
+        c:f-ref="shibboleth.MessageContextLookup.Outbound" />
+
+    <bean id="BuildEntityStatement"
+        class="net.shibboleth.oidfed.profile.impl.BuildEntityConfiguration" scope="prototype"
+        p:identifierGeneratorLookupStrategy-ref="shibboleth.oidfed.DefaultIdentifierGenerationStrategy"
+        p:objectMapper-ref="#{'%{idp.oidfed.logging.objectMapper:shibboleth.oidfed.JSONObjectMapper}'.trim()}"/>
+
+    <bean id="SignEntityStatement" class="net.shibboleth.idp.profile.impl.WebFlowMessageHandlerAdaptor"
+            scope="prototype" c:executionDirection="OUTBOUND ">
+        <constructor-arg name="messageHandler">
+            <bean id="SignEntityStatementHandler"
+                class="net.shibboleth.oidc.security.impl.SignJWTHandler" scope="prototype" p:logName="Entity Statement"
+                p:securityParametersLookupStrategy-ref="EntityStatementSecurityParametersCreationViaMessageContextStrategy"
+                p:typeHeader="entity-statement+jwt">
+                <property name="claimsToSignLookupStrategy">
+                     <bean
+                        class="net.shibboleth.oidfed.profile.impl.JWTClaimsSetFromEntityStatementLookupFunction" />
+                </property>
+                <property name="jwtUpdateConsumer">
+                    <bean
+                        class="net.shibboleth.oidfed.profile.impl.EntityStatementUpdateStrategy" />
+                </property>
+            </bean>
+        </constructor-arg>
+    </bean>
+
+    <bean id="FormOutboundMessage" class="net.shibboleth.oidfed.profile.impl.FormOutboundFederationConfigurationResponse"
+        scope="prototype" p:responseCache-ref="shibboleth.oidfed.EntityConfigurationResponseMetadataCache">
+    </bean>
+
+    <bean id="shibboleth.oidfed.EntityConfigurationMetadataSkeletonMetadataCache" parent="shibboleth.oidfed.CacheBuilder">
+        <constructor-arg>
+            <bean p:cacheId="DefaultEntityConfigurationMetadataSkeletonMetadataCache"
+                parent="shibboleth.oidfed.EntityConfigurationMetadataSkeletonMetadataCacheBuilderSpec"/>
+        </constructor-arg>
+    </bean>
+
+    <bean id="shibboleth.oidfed.EntityConfigurationMetadataSkeletonMetadataCacheBuilderSpec"
+        class="net.shibboleth.oidc.metadata.cache.impl.BatchMetadataCacheBuilderSpec"
+        p:metadataValidPredicate-ref="shibboleth.Conditions.TRUE">
+        <property name="criteriaToIdentifierStrategy">
+            <bean parent="shibboleth.Functions.Constant" c:target-ref="shibboleth.oidfed.entityId" />
+        </property>
+        <property name="identifierExtractionStrategy">
+            <bean parent="shibboleth.Functions.Constant" c:target-ref="shibboleth.oidfed.entityId" />
+        </property>
+        <property name="loadingStrategy">
+            <bean class="net.shibboleth.oidc.metadata.cache.impl.DefaultResourceLoadingStrategy">
+                <constructor-arg name="metadata">
+                    <bean class="org.springframework.core.io.Resource"
+                        factory-bean="PreferFileSystemResourceLoader" factory-method="getResource">
+                        <constructor-arg>
+                            <bean class="java.lang.String" factory-method="valueOf">
+                                <constructor-arg value="%{idp.oidfed.configuration.MetadataSkaletonFile:%{idp.home}/conf/oidfed/oidfed-entity-configuration-metadata.json}" />
+                            </bean>
+                        </constructor-arg>
+                    </bean>
+                </constructor-arg>
+            </bean>
+        </property>
+        <property name="parsingStrategy">
+            <bean class="net.shibboleth.oidc.metadata.cache.impl.DefaultJSONMapParsingStrategy"
+                c:valueClass="java.util.Map"/>
+        </property>
+        <property name="sourceMetadataExpiryStrategy">
+            <bean class="net.shibboleth.oidc.metadata.cache.impl.DefaultSourceMetadataExpirationTimeStrategy"
+                c:duration="%{idp.oidfed.configuration.MetadataSkaletonCacheLifetime:PT10M}"/>
+        </property>
+    </bean>
+
+    <bean id="PopulateOutboundInterceptContext"
+            class="net.shibboleth.idp.profile.interceptor.impl.PopulateProfileInterceptorContext" scope="prototype"
+            p:availableFlows="#{@'shibboleth.ProfileInterceptorFlowDescriptorManager'.getComponents()}"
+            p:loggingLabel="outbound">
+        <property name="activeFlowsLookupStrategy">
+            <bean class="net.shibboleth.idp.profile.config.navigate.OutboundFlowsLookupFunction" />
+        </property>
+    </bean>
+
+    <bean id="CallOutboundMessageHandler"
+        class="net.shibboleth.idp.profile.impl.WebFlowMessageHandlerAdaptor" scope="prototype"
+        c:executionDirection="OUTBOUND">
+        <constructor-arg name="messageHandler">
+            <bean class="org.opensaml.messaging.handler.impl.FunctionMessageHandler" scope="prototype">
+                <property name="functionLookupStrategy">
+                    <bean class="net.shibboleth.oidc.profile.config.navigate.MessageHandlerLookupFunction" />
+                </property>
+            </bean>
+        </constructor-arg>
+        <property name="errorEvent">
+            <util:constant static-field="org.opensaml.profile.action.EventIds.MESSAGE_PROC_ERROR" />
+        </property>
+    </bean>
+
+    <bean id="EncodeMessage" class="org.opensaml.profile.action.impl.EncodeMessage" scope="prototype"
+        p:messageEncoderFactory-ref="oidfed.messageEncoderFactory"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier"/>
+
+    <bean id="oidfed.messageEncoderFactory"
+        class="net.shibboleth.oidc.profile.encoding.impl.OIDCResponseEncoderFactory"
+        p:messageEncoder-ref="oidfed.nimbusEncoder" scope="prototype" />
+
+    <bean id="oidfed.nimbusEncoder" class="net.shibboleth.oidc.profile.encoding.impl.SimpleNimbusResponseEncoder"
+        scope="prototype" p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier" init-method="" />
+
+<!-- TODO wire for logging
+        p:objectMapper-ref="#{'%{idp.oidfed.logging.objectMapper:shibboleth.oidfed.JSONObjectMapper}'.trim()}"/>
+-->
+    <bean id="BuildErrorResponseFromEvent"
+        class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent" scope="prototype"
+        p:defaultStatusCode="500" p:defaultCode="server_error"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+        <property name="eventContextLookupStrategy">
+            <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+        </property>
+    </bean>
+
+    <bean id="PostResponsePopulateAuditContext" parent="shibboleth.AbstractPopulateAuditContext"
+        p:fieldExtractors="#{getObject('shibboleth.oidfed.PostResponseAuditExtractors') ?: getObject('shibboleth.oidfed.DefaultPostResponseAuditExtractors')}" />
+
+    <bean id="WriteAuditLog" class="net.shibboleth.idp.profile.audit.impl.WriteAuditLog" scope="prototype"
+        p:activationCondition-ref="shibboleth.ProfileAuditingCondition"
+        p:formattingMap-ref="shibboleth.AuditFormattingMap"
+        p:dateTimeFormat="#{getObject('shibboleth.AuditDateTimeFormat')}"
+        p:useDefaultTimeZone="#{getObject('shibboleth.AuditDefaultTimeZone') ?: false}"
+        p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
+
+    <bean id="RecordResponseComplete" class="net.shibboleth.idp.profile.impl.RecordResponseComplete"
+        scope="prototype" />
+
+</beans>
diff --git a/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-flow.xml b/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-flow.xml
new file mode 100644
index 0000000..be49e83
--- /dev/null
+++ b/oidfed-common-conf-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oidfed/entity-configuration/entity-configuration-flow.xml
@@ -0,0 +1,121 @@
+<flow xmlns="http://www.springframework.org/schema/webflow"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd">
+
+    <action-state id="InitializeProfileRequestContext">
+        <evaluate expression="InitializeProfileRequestContext" />
+        <evaluate expression="PopulateMetricContext" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="InitializeMandatoryContexts" />
+    </action-state>
+
+    <action-state id="InitializeMandatoryContexts">
+        <on-entry>
+            <evaluate expression="opensamlProfileRequestContext.ensureInboundMessageContext()"/>
+            <evaluate expression="opensamlProfileRequestContext.ensureOutboundMessageContext()"/>
+            <evaluate expression="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.profile.context.RelyingPartyContext))" />
+        </on-entry>
+        <evaluate expression="SelectRelyingPartyConfiguration" />
+        <evaluate expression="SelectProfileConfiguration" />
+        <evaluate expression="PopulateInboundInterceptContext" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="CheckInboundInterceptContext" />
+    </action-state>
+
+    <decision-state id="CheckInboundInterceptContext">
+        <if test="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.profile.context.ProfileInterceptorContext)).getAvailableFlows().isEmpty()"
+            then="LookupCachedResponse" else="DoInboundInterceptSubflow" />
+    </decision-state>
+
+    <subflow-state id="DoInboundInterceptSubflow" subflow="intercept">
+        <input name="calledAsSubflow" value="true" />
+        <transition on="proceed" to="LookupCachedResponse" />
+    </subflow-state>
+    
+    <action-state id="LookupCachedResponse">
+        <evaluate expression="CallInboundMessageHandler" />
+        <evaluate expression="LookupCachedResponse" />
+        <evaluate expression="'proceed'" />
+        <transition on="CachedResponseFound" to="BuildResponseMessage" />
+        <transition on="proceed" to="InitializeEntityStatementContext" />
+    </action-state>
+
+    <action-state id="InitializeEntityStatementContext">
+        <evaluate expression="InitializeEntityStatementContext"/>
+        <evaluate expression="SelectProfileConfiguration" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="BuildEntityConfiguration" />
+    </action-state>
+
+    <action-state id="BuildEntityConfiguration">
+        <evaluate expression="PopulateEntityStatementSignatureSigningParameters" />
+        <evaluate expression="BuildEntityStatement" />
+        <evaluate expression="SignEntityStatement" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="BuildResponseMessage" />
+    </action-state>
+
+    <action-state id="BuildResponseMessage">
+        <evaluate expression="FormOutboundMessage" />
+        <evaluate expression="'proceed'" />
+        
+        <transition on="proceed" to="PopulateOutboundInterceptContext" />
+    </action-state>
+
+    <action-state id="PopulateOutboundInterceptContext">
+        <evaluate expression="PopulateOutboundInterceptContext" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="CheckOutboundInterceptContext" />
+     </action-state>
+
+    <decision-state id="CheckOutboundInterceptContext">
+        <if test="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.profile.context.ProfileInterceptorContext)).getAvailableFlows().isEmpty()"
+            then="CommitResponse" else="DoOutboundInterceptSubflow" />
+    </decision-state>
+
+    <subflow-state id="DoOutboundInterceptSubflow" subflow="intercept">
+        <input name="calledAsSubflow" value="true" />
+        <transition on="proceed" to="CommitResponse" />
+        <transition to="HandleError" />
+    </subflow-state>
+
+    <action-state id="LogRuntimeException">
+        <on-entry>
+            <evaluate
+                expression="T(org.slf4j.LoggerFactory).getLogger('net.shibboleth.idp.plugin.oidc.op.profile').error('Uncaught runtime exception', flowExecutionException.getCause())" />
+        </on-entry>
+        <evaluate expression="'RuntimeException'" />
+        <transition to="HandleError" />
+    </action-state>
+    
+    <end-state id="CommitResponse">
+        <on-entry>
+            <evaluate expression="CallOutboundMessageHandler" />
+            <evaluate expression="EncodeMessage" />
+            <evaluate expression="PostResponsePopulateAuditContext" />
+            <evaluate expression="WriteAuditLog" />
+            <evaluate expression="RecordResponseComplete" />
+        </on-entry>
+    </end-state>
+
+    <action-state id="HandleError">
+        <on-entry>
+            <evaluate
+                expression="opensamlProfileRequestContext.ensureSubcontext(T(net.shibboleth.idp.profile.context.SpringRequestContext)).setRequestContext(flowRequestContext)" />
+            <evaluate expression="LogEvent" />
+        </on-entry>
+        <evaluate expression="BuildErrorResponseFromEvent" />
+        <evaluate expression="'proceed'" />
+        <transition on="proceed" to="PopulateOutboundInterceptContext"/>
+    </action-state>
+
+    <global-transitions>
+        <transition on-exception="java.lang.RuntimeException" to="LogRuntimeException" />
+        <transition on="#{!'proceed'.equals(currentEvent.id)}" to="HandleError" />
+    </global-transitions>
+
+    <bean-import resource="entity-configuration-beans.xml" />
+
+</flow>
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java
new file mode 100644
index 0000000..9e9caec
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/trustchain/DefaultTrustChainFetchingStrategyTest.java
@@ -0,0 +1,365 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.trustchain;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.SubordinateStatement;
+import net.shibboleth.oidfed.metadata.cache.PreSelectedTrustChainCriterion;
+import net.shibboleth.oidfed.metadata.cache.configuration.EntityConfigurationContainer;
+import net.shibboleth.oidfed.metadata.cache.local.LocalKeyContainer;
+import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementCacheIdentifier;
+import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementContainer;
+import net.shibboleth.oidfed.metadata.cache.trustchain.DefaultTrustChainFetchingStrategy;
+import net.shibboleth.oidfed.metadata.cache.trustchain.TrustChainsContainer;
+import net.shibboleth.oidfed.metadata.payload.EntityConfigurationPayload;
+import net.shibboleth.oidfed.metadata.util.EntityStatementHelper;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Unit tests for {@link DefaultTrustChainFetchingStrategy}
+ */
+ at SuppressWarnings("null")
+public class DefaultTrustChainFetchingStrategyTest {
+
+    DefaultTrustChainFetchingStrategy function;
+
+    Function<CriteriaSet, String> criteriaToSubjectEntityIdStrategy;
+    MetadataCache<EntityConfigurationContainer> entityConfigurationCache;
+    MetadataCache<SubordinateStatementContainer> subordinateStatementCache;
+    MetadataCache<Map<String, LocalKeyContainer>> localTrustAnchorsCache;
+    Function<CriteriaSet, Duration> criteriaToValidContainerLifetimeStrategy;
+    Function<CriteriaSet, Duration> criteriaToInvalidContainerLifetimeStrategy;
+
+    @SuppressWarnings("unchecked")
+    public void initMocks() {
+        criteriaToSubjectEntityIdStrategy = mock(Function.class);
+        entityConfigurationCache = mock(MetadataCache.class);
+        subordinateStatementCache = mock(MetadataCache.class);
+        localTrustAnchorsCache = mock(MetadataCache.class);
+        criteriaToValidContainerLifetimeStrategy = mock(Function.class);
+        criteriaToInvalidContainerLifetimeStrategy = mock(Function.class);
+    }
+
+    @BeforeMethod
+    public void setup() {
+        initMocks();
+        function = new DefaultTrustChainFetchingStrategy();
+        function.setCriteriaToSubjectEntityIdStrategy(criteriaToSubjectEntityIdStrategy);
+        function.setEntityConfigurationCache(entityConfigurationCache);
+        function.setSubordinateStatementCache(subordinateStatementCache);
+        function.setLocalTrustAnchorsCache(localTrustAnchorsCache);
+        function.setCriteriaToValidContainerLifetimeStrategy(criteriaToValidContainerLifetimeStrategy);
+        function.setCriteriaToInvalidContainerLifetimeStrategy(criteriaToInvalidContainerLifetimeStrategy);
+        function.setId("mockFunction");
+        try {
+            function.initialize();
+        } catch (ComponentInitializationException e) {
+            Assert.fail("Could not initialize the function", e);
+        }
+    }
+
+    @Test
+    public void nullCriteria_returnsNull() {
+        Assert.assertEquals(function.apply(null), null);
+    }
+
+    @Test
+    public void noValidLifetimeCriteria_returnsNull() {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(null);
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        Assert.assertEquals(function.apply(new CriteriaSet()), null);
+    }
+
+    @Test
+    public void noInvalidLifetimeCriteria_returnsNull() {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(null);
+        Assert.assertEquals(function.apply(new CriteriaSet()), null);
+    }
+
+    @Test
+    public void noEntityConfigurationResolved_returnsNull() throws MetadataCacheException {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(entityConfigurationCache.get(any())).thenReturn(CollectionSupport.emptyList());
+        Assert.assertEquals(function.apply(new CriteriaSet()), null);
+    }
+
+    @Test
+    public void entityConfigurationException_returnsNull() throws MetadataCacheException {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(entityConfigurationCache.get(any())).thenThrow(MetadataCacheException.class);
+        Assert.assertEquals(function.apply(new CriteriaSet()), null);
+    }
+
+    @Test
+    public void entityConfigurationNoHints_returnsNull() throws MetadataCacheException {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer("mockEntityId", CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any())).thenReturn(CollectionSupport.listOf(ecContainer));
+        Assert.assertEquals(function.apply(new CriteriaSet()), null);
+    }
+
+    @Test
+    public void entityConfigurationOneUnresolvableHint_returnsEmptyContainer() throws MetadataCacheException {
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer("mockEntityId", CollectionSupport.listOf("https://federation.local/immediate"));
+        when(entityConfigurationCache.get(any())).thenReturn(CollectionSupport.listOf(ecContainer));
+        assertEmptyResult(function.apply(new CriteriaSet()));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationOneResolvableHint_returnsContainerWithOneChain() throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String anchor = "https://federation.local/immediate";
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final EntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final SubordinateStatementContainer ssContainer = ssContainer(leaf, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        final TrustChainsContainer result = function.apply(new CriteriaSet());
+        Assert.assertNotNull(result);
+        assert result != null;
+        Assert.assertEquals(result.getTrustChains().size(), 1);
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(0)),
+                List.of(leaf, anchor));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationOneResolvableLocalAuthorityWithBrokenHint_returnsContainerWithOneChain()
+            throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String immediate = "https://federation.local/immediate";
+        final String anchor = "https://federation.local/anchor";
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(immediate, "https://federation.local/other"));
+        final EntityConfigurationContainer immediateContainer =
+                ecContainer(immediate, CollectionSupport.listOf(anchor));
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(immediateContainer),
+                    CollectionSupport.emptyList());
+        final SubordinateStatementContainer ssContainer = ssContainer(leaf, immediate);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        when(localTrustAnchorsCache.get(any())).thenReturn(
+                CollectionSupport.listOf(CollectionSupport.singletonMap(immediate, mock(LocalKeyContainer.class))));
+        final TrustChainsContainer result = function.apply(new CriteriaSet());
+        Assert.assertNotNull(result);
+        assert result != null;
+        Assert.assertEquals(result.getTrustChains().size(), 1);
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(0)),
+                List.of(leaf, immediate));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationOneResolvableLocalAuthorityWithWorkingHint_returnsContainerWithTwoChains()
+            throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String immediate = "https://federation.local/immediate";
+        final String anchor = "https://federation.local/anchor";
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(immediate));
+        final EntityConfigurationContainer immediateContainer =
+                ecContainer(immediate, CollectionSupport.listOf(anchor));
+        final EntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(immediateContainer),
+                    CollectionSupport.listOf(authorityContainer), CollectionSupport.emptyList());
+        final SubordinateStatementContainer ssContainer1 = ssContainer(leaf, immediate);
+        final SubordinateStatementContainer ssContainer2 = ssContainer(immediate, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer1),
+                CollectionSupport.listOf(ssContainer2), CollectionSupport.emptyList());
+        when(localTrustAnchorsCache.get(any())).thenReturn(
+                CollectionSupport.listOf(CollectionSupport.singletonMap(immediate, mock(LocalKeyContainer.class))),
+                CollectionSupport.emptyList());
+        final TrustChainsContainer result = function.apply(new CriteriaSet());
+        Assert.assertNotNull(result);
+        assert result != null;
+        Assert.assertEquals(result.getTrustChains().size(), 2);
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(0)),
+                List.of(leaf, immediate));
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(1)),
+                List.of(leaf, immediate, anchor));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationOneResolvableHint_matchPreSelected_returnsContainerWithOneChain()
+            throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String anchor = "https://federation.local/immediate";
+        final PreSelectedTrustChainCriterion preSelectedCriterion =
+                new PreSelectedTrustChainCriterion(CollectionSupport.listOf(leaf, anchor));
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final EntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final SubordinateStatementContainer ssContainer = ssContainer(leaf, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        final TrustChainsContainer result = function.apply(new CriteriaSet(preSelectedCriterion));
+        Assert.assertNotNull(result);
+        assert result != null;
+        Assert.assertEquals(result.getTrustChains().size(), 1);
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(0)),
+                List.of(leaf, anchor));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationOneResolvableHint_notMatchingPreSelected_returnsEmptyContainer()
+            throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String anchor = "https://federation.local/immediate";
+        final PreSelectedTrustChainCriterion preSelectedCriterion =
+                new PreSelectedTrustChainCriterion(CollectionSupport.listOf(leaf, "https://federation.local/not"));
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final EntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final SubordinateStatementContainer ssContainer = ssContainer(leaf, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        assertEmptyResult(function.apply(new CriteriaSet(preSelectedCriterion)));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationTwoResolvableHints_returnsContainerWithTwoChains() throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String anchor1 = "https://federation.local/immediate1";
+        final String anchor2 = "https://federation.local/immediate2";
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor1, anchor2));
+        final EntityConfigurationContainer authorityContainer1 =
+                ecContainer(anchor1, CollectionSupport.emptyList());
+        final EntityConfigurationContainer authorityContainer2 =
+                ecContainer(anchor2, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer1),
+                    CollectionSupport.listOf(authorityContainer2));
+        final SubordinateStatementContainer ssContainer1 = ssContainer(leaf, anchor1);
+        final SubordinateStatementContainer ssContainer2 = ssContainer(leaf, anchor2);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer1),
+                CollectionSupport.listOf(ssContainer2));
+        final TrustChainsContainer result = function.apply(new CriteriaSet());
+        Assert.assertNotNull(result);
+        assert result != null;
+        Assert.assertEquals(result.getTrustChains().size(), 2);
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(0)),
+                List.of(leaf, anchor1));
+        Assert.assertEquals(EntityStatementHelper.getEntityIds(result.getTrustChains().get(1)),
+                List.of(leaf, anchor2));
+    }
+
+    @SuppressWarnings("unchecked")
+    @Test
+    public void entityConfigurationAuthorityLoop_returnsEmptyContantainer() throws MetadataCacheException {
+        final String leaf = "https://federation.local/leaf";
+        final String anchor1 = "https://federation.local/immediate1";
+        final String anchor2 = "https://federation.local/immediate2";
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final EntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor1));
+        final EntityConfigurationContainer authorityContainer1 =
+                ecContainer(anchor1, CollectionSupport.listOf(anchor2));
+        final EntityConfigurationContainer authorityContainer2 =
+                ecContainer(anchor2, CollectionSupport.listOf(anchor1));
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer1),
+                    CollectionSupport.listOf(authorityContainer2));
+        final SubordinateStatementContainer ssContainer1 = ssContainer(leaf, anchor1);
+        final SubordinateStatementContainer ssContainer2 = ssContainer(anchor1, anchor2);
+        final SubordinateStatementContainer ssContainer3 = ssContainer(anchor2, anchor1);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer1),
+                CollectionSupport.listOf(ssContainer2), CollectionSupport.listOf(ssContainer3));
+        assertEmptyResult(function.apply(new CriteriaSet()));
+    }
+
+    protected void assertEmptyResult(final TrustChainsContainer container) {
+        Assert.assertNotNull(container);
+        Assert.assertEquals(container.getTrustChains(), CollectionSupport.emptyList());
+    }
+    
+    protected EntityConfigurationContainer ecContainer(final String id, final List<String> authorityHints) {
+        final Instant expiration = Instant.now().plusSeconds(300);
+        final EntityConfiguration configuration = mock(EntityConfiguration.class);
+        final EntityConfigurationPayload payload = mock(EntityConfigurationPayload.class);
+        when(payload.getAuthorityHints()).thenReturn(authorityHints);
+        when(configuration.getParsedPayload()).thenReturn(payload);
+        when(configuration.getSubject()).thenReturn(id);
+        return new EntityConfigurationContainer(id, configuration, expiration, expiration);
+    }
+
+    protected SubordinateStatementContainer ssContainer(final String subject, final String issuer) {
+        final Instant expiration = Instant.now().plusSeconds(300);
+        final SubordinateStatement statement = mock(SubordinateStatement.class);
+        when(statement.getSubject()).thenReturn(subject);
+        when(statement.getIssuer()).thenReturn(issuer);
+        return new SubordinateStatementContainer(
+                new SubordinateStatementCacheIdentifier(issuer, subject), statement, expiration, expiration);
+    }
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java
new file mode 100644
index 0000000..00f8742
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java
@@ -0,0 +1,839 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.classic.HttpClient;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.mockito.ArgumentMatcher;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.springframework.webflow.test.MockExternalContext;
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.ErrorResponse;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.Response;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+import com.nimbusds.openid.connect.sdk.SubjectType;
+import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientMetadata;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import net.minidev.json.JSONObject;
+import net.shibboleth.idp.test.flows.AbstractFlowTest;
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean;
+import net.shibboleth.oidfed.metadata.EntityStatement;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.servlet.impl.HttpServletRequestResponseContext;
+
+/**
+ * Abstract unit test for the flows supporting OpenID federation.
+ */
+ at ContextConfiguration(locations = { "classpath*:/META-INF/net.shibboleth.idp/postconfig.xml",})
+public class AbstractFederationFlowTest extends AbstractFlowTest {
+
+    public static final String USE_CUSTOM_RESOLVER_API_CONDITION = "useCustomResolverApi";
+    public static final String USE_CUSTOM_FAILBACK_TO_LOCAL_CONDITION = "useCustomFallbackToLocal";
+
+    final static AtomicInteger clientIndex = new AtomicInteger();
+    final static AtomicInteger intermediateIndex = new AtomicInteger();
+
+    @Nonnull public static final String END_STATE_ID = "CommitResponse";
+    @Nonnull protected final String flowId;
+    @Nonnull protected final String endStateId;
+
+    protected final String redirectUri = "https://rp.federation.local/cb";
+    protected final String clientIdPattern = "https://testrp%s.federation.local";
+    protected final String intermediateIdPattern = "https://intermediate-authority%s.federation.local";
+    protected final String trustedIntermediateId = "https://local-trusted-intermediate-authority.federation.local";
+    protected final String anchorId = "https://trust-anchor.federation.local";
+    protected final String anchorFetchEndpoint = anchorId + "/fetch";
+    protected final String anchorResolveEndpoint = anchorId + "/resolve";
+    protected final String trustMarkIssuerId = "https://trust-mark-issuer.federation.local";
+    protected final String trustMarkEndpoint = "https://trust-mark-issuer.federation.local/issue";
+    protected final String trustMarkStatusEndpoint = "https://trust-mark-issuer.federation.local/status";
+    protected final String issuer = "https://op.example.org";
+
+    protected static JWK rpKey;
+    protected static JWK leafKey;
+    protected static JWK anchorKey;
+    protected static JWK trustedAnchorKey;
+    protected static JWK intermediateKey;
+    protected static JWK trustedIntermediateKey;
+    protected static JWK trustMarkIssuerKey;
+
+    protected String subject = "jdoe";
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.HttpClient")
+    protected HttpClient federationHttpClient;
+
+    static {
+        try {
+            rpKey = initializeNewJwk("RSA", 2048, "mockRpKey");
+            leafKey = initializeNewJwk("RSA", 2048, "mockLeafKey");
+            anchorKey = initializeNewJwk("RSA", 2048, "mockAnchorKey");
+            final BasicJWKCredential localAnchor = loadCredential("/credentials/fed-local-anchor.jwk");
+            trustedAnchorKey = new RSAKey.Builder((RSAPublicKey) localAnchor.getPublicKey())
+                    .privateKey(localAnchor.getPrivateKey())
+                    .keyID("locallyTrustedAnchorKey")
+                    .build();
+            final BasicJWKCredential localIntermediate = loadCredential("/credentials/fed-local-intermediate.jwk");
+            trustedIntermediateKey = new RSAKey.Builder((RSAPublicKey) localIntermediate.getPublicKey())
+                    .privateKey(localIntermediate.getPrivateKey())
+                    .keyID("locallyTrustedIntermediateKey")
+                    .build();
+            intermediateKey = initializeNewJwk("RSA", 2048, "mockIntermediateKey");
+            trustMarkIssuerKey = initializeNewJwk("RSA", 2048, "mockTrustMarkIssuerKey");
+        } catch (final NoSuchAlgorithmException e) {
+            Assert.fail("Could not initialize keys for the tests", e);
+        }
+    }
+
+    protected AbstractFederationFlowTest(@Nonnull final String id) {
+        this(id, END_STATE_ID);
+    }
+
+    protected AbstractFederationFlowTest(@Nonnull final String id, @Nonnull final String endId) {
+        flowId = Constraint.isNotEmpty(id, "Flow ID cannot be empty");
+        endStateId = Constraint.isNotEmpty(endId, "End state ID cannot be empty");
+    }
+
+    /**
+     * Initialize mock request, response, and external context. Overrides to remove authorization header.
+     */
+    @Override
+    @BeforeMethod public void initializeMocks() {
+        overrideEndStateOutput(flowId, endStateId);
+
+        request = new MockHttpServletRequest();
+        response = new MockHttpServletResponse();
+        externalContext = new MockExternalContext();
+        externalContext.setNativeRequest(request);
+        externalContext.setNativeResponse(response);
+    }
+    
+    /**
+     * {@link HttpServletRequestResponseContext#loadCurrent(HttpServletRequest, HttpServletResponse)}
+     */
+    @SuppressWarnings("null")
+    @Override
+    @BeforeMethod public void initializeThreadLocals() {
+        HttpServletRequestResponseContext.loadCurrent(request, response);
+    }
+
+    @BeforeMethod
+    public void removeHeaders() {
+        request.removeHeader(USE_CUSTOM_RESOLVER_API_CONDITION);
+        request.removeHeader(USE_CUSTOM_FAILBACK_TO_LOCAL_CONDITION);
+    }
+
+    protected static JWK initializeNewJwk(final String algorithm, final int size, final String kid)
+            throws NoSuchAlgorithmException {
+        if ("RSA".equals(algorithm)) {
+            final KeyPairGenerator keyGen = KeyPairGenerator.getInstance(algorithm);
+            keyGen.initialize(size);
+            final KeyPair keyPair = keyGen.generateKeyPair();
+            return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+                    .privateKey(keyPair.getPrivate())
+                    .keyID(kid)
+                    .build();
+        }
+        throw new NoSuchAlgorithmException(algorithm);
+    }
+
+    protected JWT plainRequestObject(final Map<String,Object> claims) {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
+        for (final String claim : claims.keySet()) {
+            builder.claim(claim, claims.get(claim));
+        }
+        return new PlainJWT(builder.build());
+    }
+
+    protected JWT signedRequestObject(final Map<String,Object> claims) {
+        return signedRequestObject(claims, rpKey);
+    }
+
+    protected JWT signedRequestObject(final Map<String,Object> claims, final JWK jwk) {
+        return signedRequestObject(claims, new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(jwk.getKeyID()).build(),
+                jwk);
+    }
+
+    protected JWT signedRequestObject(final Map<String,Object> claims, final JWSHeader header, final JWK jwk) {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
+        for (final String claim : claims.keySet()) {
+            builder.claim(claim, claims.get(claim));
+        }
+        try {
+            final SignedJWT jwt = new SignedJWT(header, builder.build());
+            final RSASSASigner signer = new RSASSASigner(jwk.toRSAKey());
+            jwt.sign(signer);
+            return jwt;
+        } catch (JOSEException e) {
+            Assert.fail(e.getMessage(), e);
+        }
+        return null;
+    }
+
+    protected String entityConfigurationUrl(final String entityId) {
+        return entityId + "/.well-known/openid-federation";
+    }
+
+    protected String subordinateStatementUrl(final String fetchEndpoint, final String subject) {
+        return fetchEndpoint + "?sub=" + URLEncoder.encode(subject, Charset.forName("UTF-8"));
+    }
+
+    protected String resolveEntityUrl(final String resolveEndpoint, final String subject,
+            final String... trustAnchors) {
+        final StringBuilder builder = new StringBuilder(resolveEndpoint + "?sub=" + 
+            URLEncoder.encode(subject, Charset.forName("UTF-8")));
+        for (final String trustAnchor : trustAnchors) {
+            builder.append("&trust_anchor=" + URLEncoder.encode(trustAnchor, Charset.forName("UTF-8")));
+        }
+        builder.append("&entity_type=openid_relying_party");
+        return builder.toString();
+    }
+
+    protected void mapResponse(final String requestUri, final ClassicHttpResponse classicResponse) throws IOException {
+        when(federationHttpClient.executeOpen(any(), argThat(new RequestUriMatcher(requestUri)), any()))
+            .thenReturn(classicResponse);
+    }
+
+    protected ClassicHttpResponse mockResponse(final String contents)
+            throws UnsupportedOperationException, IOException {
+        return mockResponse(200, "application/entity-statement+jwt", contents);
+    }
+
+    protected ClassicHttpResponse mockResponse(final int code, final String contentType, final String contents)
+            throws UnsupportedOperationException, IOException {
+        final ClassicHttpResponse classicResponse = mock(ClassicHttpResponse.class);
+        when(classicResponse.getCode()).thenReturn(code);
+        final HttpEntity responseEntity = mock(HttpEntity.class);
+        when(responseEntity.getContentType()).thenReturn(contentType);
+        when(responseEntity.getContent()).thenReturn(new ByteArrayInputStream(contents.getBytes()));
+        when(classicResponse.getEntity()).thenReturn(responseEntity);
+        return classicResponse;
+    }
+
+    protected String rpEntityConfiguration(final String clientId, final String... authorityHints)
+            throws URISyntaxException {
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setJWKSet(new JWKSet(rpKey.toPublicJWK()));
+        return rpEntityConfiguration(clientId, metadata, authorityHints);
+    }
+
+    protected String rpEntityConfiguration(final String clientId, final JWK leafKey,
+            final String... authorityHints) throws URISyntaxException {
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setJWKSet(new JWKSet(rpKey.toPublicJWK()));
+        return rpEntityConfiguration(clientId, metadata, null, leafKey, authorityHints);
+    }
+
+    protected String rpEntityConfiguration(final String clientId, final OIDCClientMetadata metadata,
+            final String... authorityHints) throws URISyntaxException {
+        return rpEntityConfiguration(clientId, metadata, null, leafKey, authorityHints);
+    }
+
+    protected String rpEntityConfiguration(final String clientId, final OIDCClientMetadata metadata,
+            final List<Map<String, String>> trustMarks, final JWK leafKey, final String... authorityHints)
+                    throws URISyntaxException {
+        return rpEntityConfiguration(clientId, metadata, trustMarks, null, leafKey, authorityHints);
+    }
+
+    protected String rpEntityConfiguration(final String clientId, final OIDCClientMetadata metadata,
+            final List<Map<String, String>> trustMarks, final List<String> crit, final JWK leafKey,
+            final String... authorityHints) throws URISyntaxException {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(clientId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("authority_hints", authorityHints == null || authorityHints.length == 0 ?
+                        new String[] { anchorId } : authorityHints);
+        if (trustMarks != null) {
+            builder.claim("trust_marks", trustMarks);
+        }
+        if (crit != null) {
+            builder.claim("crit", crit);
+        }
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, leafKey, builder.build());
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String rpEntityConfigurationUnmatchingKey(final String clientId,
+            final OIDCClientMetadata metadata, final String... authorityHints) throws URISyntaxException {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(clientId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("authority_hints", authorityHints == null || authorityHints.length == 0 ?
+                        new String[] { anchorId } : authorityHints)
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, anchorKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String trustMarkIssuerConfiguration(final String entityId, final String... authorityHints) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(trustMarkIssuerKey).toJSONObject(true))
+                .claim("authority_hints", authorityHints == null || authorityHints.length == 0 ?
+                        new String[] { anchorId } : authorityHints)
+                .claim("metadata", Map.of("federation_entity", Map.of("trust_mark_endpoint",
+                        trustMarkEndpoint, "federation_trust_mark_status_endpoint", trustMarkStatusEndpoint)))
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, trustMarkIssuerKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+    
+    protected String opEntityConfiguration(final String issuer, final String... authorityHints)
+            throws URISyntaxException {
+        return opEntityConfiguration(issuer, emptyOpMetadata(issuer), authorityHints);
+    }
+
+    protected String opEntityConfiguration(final String issuer, final OIDCProviderMetadata metadata,
+            final String... authorityHints) throws URISyntaxException {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(issuer).subject(issuer)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_provider", metadata.toJSONObject()))
+                .claim("authority_hints", authorityHints == null || authorityHints.length == 0 ?
+                        new String[] { anchorId } : authorityHints)
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, leafKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String entityConfiguration(final String entityId, final Map<String, Object> metadata,
+            final String... authorityHints) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", metadata)
+                .claim("authority_hints", authorityHints == null || authorityHints.length == 0 ?
+                        new String[] { anchorId } : authorityHints)
+                .build();
+        final EntityStatement<?> configuration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, leafKey, claimsSet);
+        return configuration.getJwt().serialize();
+    }
+
+    protected String trustedAnchorConfiguration() {
+        return trustedAnchorConfiguration(null);
+    }
+
+    protected String trustedAnchorConfiguration(final Map<String, Object> constraints) {
+        return trustedAnchorConfiguration(constraints,
+                Map.of("https://example.org/email-allowing-trust-mark", List.of(trustMarkIssuerId)));
+    }
+
+    protected String trustedAnchorConfiguration(final Map<String, Object> constraints,
+            final Map<String,List<String>> trustMarkIssuers) {
+        return trustedAnchorConfiguration(constraints, trustMarkIssuers, trustedAnchorKey,
+                new JWKSet(trustedAnchorKey));
+    }
+
+    protected String trustedAnchorConfiguration(final Map<String, Object> constraints,
+            final Map<String,List<String>> trustMarkIssuers, final JWK signerKey, final JWKSet jwks) {
+
+        final String anchorId = "https://trust-anchor.federation.local";
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(anchorId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", jwks.toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Map.of("federation_fetch_endpoint",
+                        anchorFetchEndpoint, "federation_resolve_endpoint", anchorResolveEndpoint)));
+        if (constraints != null) {
+            builder.claim("constraints", constraints);
+        }
+        if (trustMarkIssuers != null) {
+            builder.claim("trust_mark_issuers", trustMarkIssuers);
+        }
+        final EntityStatement<?> anchorConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, signerKey, builder.build());
+        return anchorConfiguration.getJwt().serialize();
+    }
+
+    protected String intermediateConfiguration(final String intermediateId) {
+        return intermediateConfiguration(intermediateId, new JWKSet(intermediateKey), intermediateKey);
+    }
+
+    protected String intermediateConfiguration(final String intermediateId, final JWKSet jwks, final JWK signerKey) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(intermediateId).subject(intermediateId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", jwks.toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Map.of("federation_fetch_endpoint",
+                        intermediateId + "/fetch")))
+                .claim("authority_hints", new String[] { anchorId })
+                .build();
+        final EntityStatement<?> intermediateConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, signerKey, claimsSet);
+        return intermediateConfiguration.getJwt().serialize();
+    }
+
+    protected String subordinateStatement(final String issuer, final Map<String, Object> metadata) {
+        return subordinateStatement(issuer, metadata, leafKey);
+    }
+
+    protected String subordinateStatement(final String issuer, final Map<String, Object> metadata,
+            final List<String> metadataPolicyCrit, final List<String> crit) {
+        return subordinateStatement(issuer, metadata, metadataPolicyCrit, crit, leafKey);
+    }
+
+    protected String subordinateStatement(final String issuer, final Map<String, Object> metadata,
+            final JWK subjectKey) {
+        return subordinateStatement(issuer, metadata, null, null, subjectKey);
+    }
+
+    protected String subordinateStatement(final String issuer, final Map<String, Object> metadata,
+            final List<String> metadataPolicyCrit, final List<String> crit, final JWK subjectKey) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(anchorId).subject(issuer)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(subjectKey).toJSONObject(true))
+                .claim("metadata", metadata)
+                .claim("metadata_policy_crit", metadataPolicyCrit)
+                .claim("crit", crit)
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, trustedAnchorKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String rpSubordinateStatement(final String issuer, final JWK issuerKey, final JWK subjetKey,
+            final String subjectId, final Map<String, Object> rpPolicy, final String... authorityHints) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(issuer).subject(subjectId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(subjetKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", new OIDCClientMetadata().toJSONObject()))
+                .claim("metadata_policy", rpPolicy)
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, issuerKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String subordinateStatement(final String issuer, final JWK issuerKey, final JWK subjetKey,
+            final String subjectId, final Map<String, Object> rpPolicy, final Map<String, Object> constraints) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(issuer).subject(subjectId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(subjetKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", new OIDCClientMetadata().toJSONObject()))
+                .claim("metadata_policy", rpPolicy)
+                .claim("constraints", constraints)
+                .build();
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, issuerKey, claimsSet);
+        return rpConfiguration.getJwt().serialize();
+    }
+
+    protected String uniqueClientId() {
+        return String.format(clientIdPattern, clientIndex.getAndIncrement());
+    }
+
+    protected String uniqueIntermediateId() {
+        return String.format(intermediateIdPattern, intermediateIndex.getAndIncrement());
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final URI jwksUri) {
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        try {
+            metadata.setRedirectionURI(new URI(redirectUri));
+            metadata.setJWKSetURI(jwksUri);
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final JWK leafKey) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId),
+                    mockResponse(rpEntityConfiguration(clientId, leafKey)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final OIDCClientMetadata metadata) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final JSONObject metadata) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party", metadata))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final String rpEntityConfiguration) {
+        rpConfigureMockHttpClient(clientId, rpEntityConfiguration, trustedAnchorConfiguration());
+    }
+
+    protected void rpConfigureMockHttpClient(final String clientId, final String rpEntityConfiguration,
+            final String trustedAnchorConfiguration) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClientWithAnchorConstraints(final String clientId,
+            final Map<String,Object> constraints) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(anchorId, trustedAnchorKey, leafKey, clientId,
+                            Map.of("openid_relying_party", Collections.emptyMap()), constraints)));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClientWithMetadataCrit(final String clientId, final List<String> crit) {
+        try {
+            mapResponse(entityConfigurationUrl(clientId), mockResponse(rpEntityConfiguration(clientId)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()), crit, null)));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void rpConfigureMockHttpClientWithCrit(final String clientId, final List<String> configCrit,
+            final List<String> subordinateCrit) {
+        try {
+            final OIDCClientMetadata metadata = new OIDCClientMetadata();
+            metadata.setRedirectionURI(new URI(redirectUri));
+            metadata.setJWKSet(new JWKSet(rpKey.toPublicJWK()));
+            mapResponse(entityConfigurationUrl(clientId),
+                    mockResponse(rpEntityConfiguration(clientId, metadata, null, configCrit, rpKey)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, clientId),
+                    mockResponse(subordinateStatement(clientId, Map.of("openid_relying_party",
+                            new OIDCClientMetadata().toJSONObject()), null, subordinateCrit)));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    protected void rpConfigureMockHttpClient(final String clientId, final Map<String, Object> testVector) {
+        final String intermediateId = uniqueIntermediateId();
+        try {
+            final Map<String, Object> vectorMetadata = (Map<String, Object>) testVector.get("metadata");
+            if (vectorMetadata.isEmpty()) {
+                mapResponse(entityConfigurationUrl(clientId), mockResponse(
+                        rpEntityConfiguration(clientId, intermediateId)));
+            } else {
+                final Map<String, Object> metadata = new HashMap<>(vectorMetadata);
+                metadata.put("redirect_uris", List.of(redirectUri));
+                metadata.put("jwks", new JWKSet(rpKey.toPublicJWK()).toJSONObject());
+                mapResponse(entityConfigurationUrl(clientId),
+                        mockResponse(rpEntityConfiguration(clientId,
+                                OIDCClientMetadata.parse(new JSONObject(metadata)), intermediateId)));
+            }
+            mapResponse(entityConfigurationUrl(intermediateId), mockResponse(intermediateConfiguration(intermediateId)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, intermediateId),
+                    mockResponse(rpSubordinateStatement(anchorId, trustedAnchorKey, intermediateKey, intermediateId,
+                            Map.of("openid_relying_party", (Map<String, Object>) testVector.get("TA")), anchorId)));
+            mapResponse(subordinateStatementUrl(intermediateId + "/fetch", clientId),
+                    mockResponse(rpSubordinateStatement(intermediateId, intermediateKey, leafKey, clientId,
+                            Map.of("openid_relying_party", (Map<String, Object>) testVector.get("INT")),
+                            intermediateId)));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException | ParseException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected OIDCProviderMetadata emptyOpMetadata(final String issuer) {
+        return new OIDCProviderMetadata(new Issuer(issuer), List.of(SubjectType.PUBLIC),
+                URI.create("https://mock.example.org/jwks"));
+    }
+
+    protected void opConfigureMockHttpClient(final String issuer) {
+        try {
+            mapResponse(entityConfigurationUrl(issuer), mockResponse(opEntityConfiguration(issuer)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, issuer),
+                    mockResponse(subordinateStatement(issuer,  Map.of("openid_provider",
+                            emptyOpMetadata(issuer).toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void opConfigureMockHttpClient(final String issuer, final OIDCProviderMetadata metadata) {
+        try {
+            mapResponse(entityConfigurationUrl(issuer), mockResponse(opEntityConfiguration(issuer, metadata)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, issuer),
+                    mockResponse(subordinateStatement(issuer,  Map.of("openid_provider",
+                            metadata.toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void opConfigureMockHttpClient(final String issuer, final JSONObject metadata) {
+        try {
+            mapResponse(entityConfigurationUrl(issuer), mockResponse(opEntityConfiguration(issuer)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, issuer),
+                    mockResponse(subordinateStatement(issuer,  Map.of("openid_provider", metadata))));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void opConfigureMockHttpClient(final String issuer, final String opEntityConfiguration) {
+        try {
+            mapResponse(entityConfigurationUrl(issuer), mockResponse(opEntityConfiguration));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, issuer),
+                    mockResponse(subordinateStatement(issuer,  Map.of("openid_provider",
+                            emptyOpMetadata(issuer).toJSONObject()))));
+        } catch (UnsupportedOperationException | IOException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected void opConfigureMockHttpClientWithAnchorConstraints(final String issuer,
+            final Map<String,Object> constraints) {
+        try {
+            mapResponse(entityConfigurationUrl(issuer), mockResponse(opEntityConfiguration(issuer)));
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, issuer),
+                    mockResponse(subordinateStatement(anchorId, trustedAnchorKey, leafKey, issuer,
+                            Map.of("openid_provider", Collections.emptyMap()), constraints)));
+        } catch (UnsupportedOperationException | IOException | URISyntaxException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+    }
+
+    protected List<Map<String, Object>> loadPolicyTestVectors() throws IOException {
+        final ObjectMapper objectMapper = new ObjectMapper();
+        final Resource file = new ClassPathResource(
+                "/net/shibboleth/idp/oidc/metadata/impl/metadata-policy-test-vectors-2025-02-13.json");
+
+        final Charset utf8 = Charset.forName("UTF-8");
+        assert utf8 != null;
+        try {
+            final List<Map<String, Object>> policies =
+                    objectMapper.readValue(file.getContentAsString(utf8),
+                            new TypeReference<List<Map<String, Object>>>(){});
+            Assert.assertNotNull(policies);
+            return policies;
+        } catch (final JsonProcessingException e) {
+            throw new IOException("Could not parse JSON from the input", e);
+        }
+    }
+
+    protected ErrorResponse parseErrorResponse(final FlowExecutionResult result, final String message) {
+        final Response response = parseResponse(result);
+        Assert.assertFalse(response.indicatesSuccess(), message);
+        Assert.assertTrue(response instanceof ErrorResponse, message);
+        return (ErrorResponse) response;
+    }
+
+    protected void assertErrorCode(final FlowExecutionResult result, final String errorCode,
+            final String message) {
+        final ErrorResponse errorResponse = parseErrorResponse(result, message);
+        Assert.assertEquals(errorResponse.getErrorObject().getCode(), errorCode, message);
+    }
+
+    protected void assertErrorDescriptionContains(final FlowExecutionResult result, final String errorDescription,
+            final String message) {
+        final ErrorResponse errorResponse = parseErrorResponse(result, message);
+        Assert.assertNotNull(errorResponse.getErrorObject().getDescription(), message);
+        Assert.assertTrue(errorResponse.getErrorObject().getDescription().contains(errorDescription), message);
+    }
+
+    protected void populateClientAssertionParams(final Map<String, String> requestParameters, 
+            final JWT jwt) {
+        requestParameters.put("client_assertion", jwt.serialize());
+        requestParameters.put("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+    }
+
+    protected JWTClaimsSet validJwtAuthenticationClaimsSet(final String clientId, final String audience) {
+        return new JWTClaimsSet.Builder()
+                .subject(clientId)
+                .issuer(clientId)
+                .audience(audience)
+                .expirationTime(Date.from(Instant.now().plusSeconds(600)))
+                .jwtID(idGenerator.generateIdentifier())
+                .build();
+    }
+
+    protected String trustMarkStatusResponse(final String issuer, final String trustMark, final String status,
+            final JWK signerKey) {
+        final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder().issuer(issuer)
+                .issueTime(Date.from(Instant.now()))
+                .claim("trust_mark", trustMark)
+                .claim("status", status)
+                .build();
+        return TrustChainTestUtil.signedJwt(JWSAlgorithm.RS256, signerKey,
+                "trust-mark-status-response+jwt", claimsSet).serialize();
+    }
+
+    protected static BasicJWKCredential loadCredential(final String classPathLocation) {
+        final BasicJWKCredentialFactoryBean factory = new BasicJWKCredentialFactoryBean();
+        factory.setResource(new ClassPathResource(classPathLocation));
+        try {
+            factory.afterPropertiesSet();
+            return factory.getObject();
+        } catch (final Exception e) {
+            Assert.fail();
+            return null;
+        }
+    }
+
+    protected Response parseResponse(final FlowExecutionResult result) {
+        assertFlowExecutionOutcome(result.getOutcome(), END_STATE_ID);
+        final ProfileRequestContext prc = retrieveProfileRequestContext(result);
+        Assert.assertNotNull(prc);
+        assert prc != null;
+        Assert.assertNotNull(prc.ensureOutboundMessageContext());
+        final Object responseMessage = prc.ensureOutboundMessageContext().getMessage();
+        Assert.assertNotNull(responseMessage);
+        Assert.assertTrue(responseMessage instanceof Response);
+        return (Response) responseMessage;
+    }
+
+    protected class RequestUriMatcher implements ArgumentMatcher<ClassicHttpRequest> {
+
+        @Nonnull private final String uri;
+
+        public RequestUriMatcher(final String value) {
+            uri = Constraint.isNotEmpty(value, "URI value cannot be null");
+        }
+
+        @Override
+        public boolean matches(final ClassicHttpRequest match) {
+            try {
+                return match != null && uri.equals(match.getUri().toString());
+            } catch (URISyntaxException e) {
+            }
+            return false;
+        }
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java
new file mode 100644
index 0000000..d0b1afd
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jose.Algorithm;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.Response;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomEntityConfigurationMetadataDecorator;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.cache.trustmark.DefaultTrustMarkFetchingStrategy;
+import net.shibboleth.oidfed.metadata.impl.EntityConfigurationImpl;
+import net.shibboleth.oidfed.metadata.payload.EntityConfigurationPayload;
+import net.shibboleth.oidfed.metadata.payload.claim.Metadata;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * Unit test for the entity configuration flow.
+ */
+ at SuppressWarnings("null")
+public class EntityConfigurationFlowTest extends AbstractFederationFlowTest {
+
+    public static final String FLOW_ID = "oidfed/entity-configuration";
+
+    final String dynamicTrustMarkIssuerId = "https://dyn-trust-mark-issuer.federation.local";
+    final String dynamicTrustMarkType = dynamicTrustMarkIssuerId + "/example";
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.JWTPayloadJSONObjectMapper")
+    ObjectMapper payloadObjectMapper;
+
+    protected EntityConfigurationFlowTest() {
+        super(FLOW_ID);
+    }
+
+    @Test
+    public void testOutputAndCaching() throws ParseException, IOException, InterruptedException {
+        final String trustMark = TrustChainTestUtil.trustMark(JWSAlgorithm.RS256, trustMarkIssuerKey,
+                dynamicTrustMarkIssuerId, issuer, dynamicTrustMarkType, Instant.now().plusSeconds(300)).serialize();
+        try {
+            mapResponse(entityConfigurationUrl(dynamicTrustMarkIssuerId),
+                    mockResponse(trustMarkIssuerConfiguration(dynamicTrustMarkIssuerId)));
+
+            mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+            mapResponse(subordinateStatementUrl(anchorFetchEndpoint, dynamicTrustMarkIssuerId),
+                    mockResponse(subordinateStatement(dynamicTrustMarkIssuerId,
+                            Map.of("federation_entity", CollectionSupport.emptyMap()), trustMarkIssuerKey)));
+            final String trustMarkUrl = trustMarkEndpoint + "?trust_mark_type=" 
+                    + URLEncoder.encode(dynamicTrustMarkType, Charset.forName("UTF-8")) 
+                    + "&sub=" + URLEncoder.encode(issuer, Charset.forName("UTF-8"));
+            mapResponse(trustMarkUrl,
+                    mockResponse(200, DefaultTrustMarkFetchingStrategy.HTTP_RESPONSE_CONTENT_TYPE.toString(),
+                            trustMark));
+        } catch (UnsupportedOperationException | IOException e) {
+            Assert.fail("Could not initialize mock HTTP client", e);
+        }
+
+        request.setRequestURI("/idp/profile/oidfed/entity-configuration");
+        final FlowExecutionResult result = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final Response response = parseResponse(result);
+        Assert.assertTrue(response.indicatesSuccess());
+        assertEntityStatement(response);
+        
+        final FlowExecutionResult result2 = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final Response response2 = parseResponse(result2);
+        Assert.assertEquals(response2.toHTTPResponse().getContent(), response.toHTTPResponse().getContent());
+        
+        Thread.sleep(2000);
+        final FlowExecutionResult result3 = flowExecutor.launchExecution(FLOW_ID, null, externalContext);
+        final Response response3 = parseResponse(result3);
+        assertEntityStatement(response3);
+        Assert.assertNotEquals(response3.toHTTPResponse().getContent(), response.toHTTPResponse().getContent());
+    }
+
+    protected void assertEntityStatement(final Response response) throws ParseException {
+        final EntityConfiguration entityStatement;
+        try {
+            entityStatement = EntityConfigurationImpl.parse(
+                    SignedJWT.parse(response.toHTTPResponse().getContent()), payloadObjectMapper);
+        } catch (JsonProcessingException | java.text.ParseException e) {
+            Assert.fail();
+            return;
+        }
+        final Metadata metadata = entityStatement.getParsedPayload().getMetadata();
+        Assert.assertNotNull(metadata);
+        final Map<String,Object> entityMetadata = metadata.getFederationEntityMetadata();
+        Assert.assertNotNull(entityMetadata);
+        assert entityMetadata != null;
+        Assert.assertEquals(entityMetadata.get("organization_name"), "Example organization");
+        Assert.assertEquals(entityMetadata.get("contacts"), List.of("contact at example.org"));
+        final EntityConfigurationPayload payload = (EntityConfigurationPayload) entityStatement.getParsedPayload();
+        Assert.assertEquals(payload.getAuthorityHints(),
+                List.of("https://anchor1.example.org","https://anchor2.example.org"));
+        Assert.assertEquals(payload.getTrustAnchorHints(), List.of(anchorId, trustedIntermediateId));
+        final List<Map<String,String>> trustMarkMap = payload.getTrustMarks();
+        Assert.assertNotNull(trustMarkMap);
+        assert trustMarkMap != null;
+        Assert.assertEquals(trustMarkMap.size(), 2);
+        Assert.assertNotNull(trustMarkMap.stream()
+                .filter(map -> map.entrySet().stream()
+                        .filter(entry -> "trust_mark_type".equals(entry.getKey()) &&
+                                "https://example.org/a-trust-mark".equals(entry.getValue()))
+                        .findAny().isPresent())
+                .findAny().orElse(null));
+        Assert.assertNotNull(trustMarkMap.stream()
+                .filter(map -> map.entrySet().stream()
+                        .filter(entry -> "trust_mark_type".equals(entry.getKey()) &&
+                                dynamicTrustMarkType.equals(entry.getValue()))
+                        .findAny().isPresent())
+                .findAny().orElse(null));
+        final Map<String,Object> rawMetadataExtension =
+                metadata.getAllClaims().get(CustomEntityConfigurationMetadataDecorator.CUSTOM_ENTITY_TYPE);
+        Assert.assertNotNull(rawMetadataExtension);
+        Assert.assertEquals(rawMetadataExtension.size(), 3);
+        Assert.assertEquals(rawMetadataExtension.get("key0"), "static_value");
+        Assert.assertEquals(rawMetadataExtension.get("key1"), "value1");
+        Assert.assertEquals(rawMetadataExtension.get("key2"), "value2");
+    }
+
+    protected boolean containsAll(Collection<? extends Algorithm> algs, Collection<String> strings) {
+        final List<String> algStrings = new ArrayList<>();
+        for (final Algorithm alg : algs) {
+            algStrings.add(alg.toString());
+        }
+        return strings.size() == algStrings.size() ? strings.containsAll(strings) : false;
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/EntityConfigurationMetadataCacheTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/EntityConfigurationMetadataCacheTest.java
new file mode 100644
index 0000000..4274fc8
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/EntityConfigurationMetadataCacheTest.java
@@ -0,0 +1,594 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.cache;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.EncryptionMethod;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWEAlgorithm;
+import com.nimbusds.jose.JWEObject;
+import com.nimbusds.jose.JWEHeader;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.Payload;
+import com.nimbusds.jose.crypto.RSAEncrypter;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.AbstractFederationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.EntityConfigurationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomEntityConfigurationFilterStrategy;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.cache.ResponseContainerExpirationCriterion;
+import net.shibboleth.oidfed.metadata.cache.SubjectEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.configuration.EntityConfigurationContainer;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Unit tests for the default entity configuration metadata cache.
+ */
+ at SuppressWarnings("null")
+public class EntityConfigurationMetadataCacheTest extends AbstractFederationFlowTest {
+
+    protected EntityConfigurationMetadataCacheTest() {
+        super(EntityConfigurationFlowTest.FLOW_ID);
+    }
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.EntityConfigurationMetadataCache")
+    MetadataCache<EntityConfigurationContainer> entityConfigurationCache;
+
+    @Test
+    public void testValidEntityConfiguration()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        try {
+            final List<EntityConfigurationContainer> result =
+                    entityConfigurationCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300))));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final EntityConfiguration statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertNull(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomEntityConfigurationFilterStrategy.CUSTOM_CLAIM_NAME));
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidEntityConfiguration_customCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("default_crit"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        try {
+            final List<EntityConfigurationContainer> result =
+                    entityConfigurationCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300))));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final EntityConfiguration statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertEquals(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomEntityConfigurationFilterStrategy.CUSTOM_CLAIM_NAME),
+                    CustomEntityConfigurationFilterStrategy.CUSTOM_CLAIM_VALUE);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testUnsignedEntityConfiguration()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = new PlainJWT(builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testEncryptedEntityConfiguration()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final SignedJWT signedJwt = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build());
+        final JWEObject jwe = new JWEObject(new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
+                    .contentType("JWT")
+                    .build(),
+                new Payload(signedJwt));
+        try {
+            jwe.encrypt(new RSAEncrypter(leafKey.toRSAKey()));
+        } catch (JOSEException e) {
+            Assert.fail("Encryption failed", e);
+        }
+        final String entityConfiguration = jwe.serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testWithUnrecognizedCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("subordinate_crit"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testWithStandardClaimAsCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("jwks"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testSignatureWithNonMathchingKey()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, anchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testNonMatchingSubjectVsIssuer()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(uniqueClientId()).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testValidClaims_nonMatchingSubject()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String claimsEntityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(claimsEntityId).subject(claimsEntityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testValidClaims_invalidTypeHeader()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testIssuedInFuture()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now().plusSeconds(300)))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testMissingSub()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testExpired()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().minusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testMissingJwks()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidJwksClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testValidClaims_forbiddenHeader_trustChain()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .type(new JOSEObjectType("entity-statement+jwt"))
+                .keyID(leafKey.getKeyID())
+                .customParam("trust_chain", "forbidden")
+                .build();
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, header, builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testValidClaims_forbiddenHeader_peerTrustChain()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .type(new JOSEObjectType("entity-statement+jwt"))
+                .keyID(leafKey.getKeyID())
+                .customParam("peer_trust_chain", "forbidden")
+                .build();
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, header, builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testEmptyAuthorityHintsClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("authority_hints", CollectionSupport.emptyList())
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testEmptyTrustAnchorHintsClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_anchor_hints", CollectionSupport.emptyList())
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testNullFederationEntityMetadata()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String, Object> metadata = new HashMap<>();
+        metadata.put("federation_entity", null);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", metadata);
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testNullCustomMetadata()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String, Object> metadata = new HashMap<>();
+        metadata.put("federation_entity", Collections.emptyMap());
+        metadata.put("custom_type", null);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", metadata);
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testNullResponse()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        mapResponse(entityConfigurationUrl(entityId), null);
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testExceptionResponse()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        when(federationHttpClient.executeOpen(any(), argThat(new RequestUriMatcher(entityConfigurationUrl(entityId))),
+                any())).thenThrow(IOException.class);
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidtTrustMark_nonMatchingType()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String trustMark = TrustChainTestUtil.trustMark(JWSAlgorithm.RS256, trustMarkIssuerKey, trustMarkIssuerId,
+                entityId, "https://example.org/email-allowing-trust-mark", Instant.now().plusSeconds(300)).serialize();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_marks", List.of(Map.of(
+                        "trust_mark_type", "https://example.org/non-matching-type",
+                        "trust_mark", trustMark)))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidtTrustMarkIssuers_invalidFormat()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_issuers", List.of(trustMarkIssuerId))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidtTrustMarkOwners_invalidJwks()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String,Object> trustMarkOwner = new HashMap<>();
+        trustMarkOwner.put("sub", trustMarkIssuerId);
+        trustMarkOwner.put("jwks", Collections.emptyMap());
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_owners", Map.of("https://example.org/email-allowing-trust-mark", trustMarkOwner))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidtTrustMarkOwners_missingJwks()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String,Object> trustMarkOwner = new HashMap<>();
+        trustMarkOwner.put("sub", trustMarkIssuerId);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_owners", Map.of("https://example.org/email-allowing-trust-mark", trustMarkOwner))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    @Test
+    public void testInvalidtTrustMarkOwners_missingSub()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String,Object> trustMarkOwner = new HashMap<>();
+        trustMarkOwner.put("jwks", new JWKSet(leafKey).toJSONObject(true));
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_owners", Map.of("https://example.org/email-allowing-trust-mark", trustMarkOwner))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        assertNoEntityConfiguration(entityId);
+    }
+
+    protected void assertNoEntityConfiguration(final String entityId) {
+        try {
+            final List<EntityConfigurationContainer> result =
+                    entityConfigurationCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300))));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 0);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+        
+    }
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SignedKeysetMetadataCacheTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SignedKeysetMetadataCacheTest.java
new file mode 100644
index 0000000..a0bd44a
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SignedKeysetMetadataCacheTest.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.cache;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientMetadata;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.AbstractFederationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.EntityConfigurationFlowTest;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.EntityStatement;
+import net.shibboleth.oidfed.metadata.SignedKeyset;
+import net.shibboleth.oidfed.metadata.cache.SubjectEntityStatementCriterion;
+import net.shibboleth.oidfed.metadata.cache.keyset.SignedKeysetContainer;
+import net.shibboleth.oidfed.metadata.cache.keyset.SubjectSignedKeysetUriCriterion;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Unit tests for the default signed keysetn metadata cache.
+ */
+ at SuppressWarnings("null")
+public class SignedKeysetMetadataCacheTest extends AbstractFederationFlowTest {
+
+    protected SignedKeysetMetadataCacheTest() {
+        super(EntityConfigurationFlowTest.FLOW_ID);
+    }
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.SignedKeysetMetadataCache")
+    MetadataCache<SignedKeysetContainer> signedKeysetCache;
+
+    @Test
+    public void testValidSignedKeyset()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "jwk-set+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/jwk-set+jwt", signedKeyset));
+        try {
+            final List<SignedKeysetContainer> result =
+                    signedKeysetCache.get(new CriteriaSet(
+                            new SubjectSignedKeysetUriCriterion(signedJwksUri),
+                            new SubjectEntityStatementCriterion(buildEntityConfiguration(entityId, metadata))));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SignedKeyset statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            final List<JWK> keys = statement.getParsedPayload().getKeys();
+            Assert.assertNotNull(keys);
+            Assert.assertEquals(keys.size(), 1);
+            Assert.assertEquals(keys.get(0), rpKey.toPublicJWK());
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidSignedKeyset_optionalExpiration()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "jwk-set+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/jwk-set+jwt", signedKeyset));
+        try {
+            final List<SignedKeysetContainer> result =
+                    signedKeysetCache.get(new CriteriaSet(
+                            new SubjectSignedKeysetUriCriterion(signedJwksUri),
+                            new SubjectEntityStatementCriterion(buildEntityConfiguration(entityId, metadata))));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SignedKeyset statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            final List<JWK> keys = statement.getParsedPayload().getKeys();
+            Assert.assertNotNull(keys);
+            Assert.assertEquals(keys.size(), 1);
+            Assert.assertEquals(keys.get(0), rpKey.toPublicJWK());
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testInvalidSignedKeyset_expired()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().minusSeconds(300)))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "jwk-set+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/jwk-set+jwt", signedKeyset));
+        assertNoSignedKeyset(signedJwksUri, buildEntityConfiguration(entityId, metadata));
+    }
+
+    @Test
+    public void testInvalidSignedKeyset_invalidStatementType()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/jwk-set+jwt", signedKeyset));
+        assertNoSignedKeyset(signedJwksUri, buildEntityConfiguration(entityId, metadata));
+    }
+
+    @Test
+    public void testInvalidSignedKeyset_invalidResponseContentType()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "jwk-set+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/entity-statement+jwt", signedKeyset));
+        assertNoSignedKeyset(signedJwksUri, buildEntityConfiguration(entityId, metadata));
+    }
+
+    @Test
+    public void testInvalidSignedKeyset_wrongKey()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String signedJwksUri = entityId + "/jwks.jwt";
+        final OIDCClientMetadata metadata = new OIDCClientMetadata();
+        metadata.setRedirectionURI(new URI(redirectUri));
+        metadata.setCustomField("signed_jwks_uri", signedJwksUri);
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().minusSeconds(300)))
+                .claim("keys", new JWKSet(rpKey).toJSONObject(true).get("keys"));
+        final String signedKeyset = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, rpKey, "jwk-set+jwt", builder.build()).serialize();
+        
+        mapResponse(signedJwksUri, mockResponse(200, "application/jwk-set+jwt", signedKeyset));
+        assertNoSignedKeyset(signedJwksUri, buildEntityConfiguration(entityId, metadata));
+    }
+
+    protected EntityConfiguration buildEntityConfiguration(final String clientId, final OIDCClientMetadata metadata) {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(clientId).subject(clientId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("openid_relying_party", metadata.toJSONObject()))
+                .claim("authority_hints", new String[] { anchorId });
+        final EntityStatement<?> rpConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, leafKey, builder.build());
+        return (EntityConfiguration) rpConfiguration;
+    }
+
+    protected void assertNoSignedKeyset(final String signedJwksUri, final EntityConfiguration entityConfiguration) {
+        try {
+            final List<SignedKeysetContainer> result =
+                    signedKeysetCache.get(new CriteriaSet(
+                            new SubjectSignedKeysetUriCriterion(signedJwksUri),
+                            new SubjectEntityStatementCriterion(entityConfiguration)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 0);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve signed keyset", e);
+        }
+        
+    }
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SubordinateStatementMetadataCacheTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SubordinateStatementMetadataCacheTest.java
new file mode 100644
index 0000000..dda1519
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/SubordinateStatementMetadataCacheTest.java
@@ -0,0 +1,665 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.cache;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.EncryptionMethod;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWEAlgorithm;
+import com.nimbusds.jose.JWEHeader;
+import com.nimbusds.jose.JWEObject;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.Payload;
+import com.nimbusds.jose.crypto.RSAEncrypter;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.AbstractFederationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.EntityConfigurationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomSubordinateStatementFilterStrategy;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.EntityStatement;
+import net.shibboleth.oidfed.metadata.SubordinateStatement;
+import net.shibboleth.oidfed.metadata.cache.IssuerEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.ResponseContainerExpirationCriterion;
+import net.shibboleth.oidfed.metadata.cache.SubjectEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementContainer;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Unit tests for the default entity configuration metadata cache.
+ */
+ at SuppressWarnings("null")
+public class SubordinateStatementMetadataCacheTest extends AbstractFederationFlowTest {
+
+    protected SubordinateStatementMetadataCacheTest() {
+        super(EntityConfigurationFlowTest.FLOW_ID);
+    }
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.SubordinateEntityStatementMetadataCache")
+    MetadataCache<SubordinateStatementContainer> subordinateStatementCache;
+
+    @Test
+    public void testValidSubordinateStatement()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<SubordinateStatementContainer> result =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SubordinateStatement statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertNull(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomSubordinateStatementFilterStrategy.CUSTOM_CLAIM_NAME));
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidSubordinateStatement_clientAuthentication()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String intermediateId = uniqueIntermediateId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(intermediateId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, intermediateKey, "entity-statement+jwt", builder.build()).serialize();
+
+        final JWTClaimsSet.Builder ecBuilder = new JWTClaimsSet.Builder().issuer(intermediateId).subject(intermediateId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(intermediateKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Map.of(
+                        "federation_fetch_endpoint", intermediateId + "/fetch",
+                        "federation_fetch_endpoint_auth_methods", List.of("private_key_jwt"),
+                        "endpoint_auth_signing_alg_values_supported", List.of("ES512"))));
+        final EntityStatement<?> intermediateConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, intermediateKey, ecBuilder.build());
+
+
+        mapResponse(entityConfigurationUrl(intermediateId), mockResponse(intermediateConfiguration.getJwt().serialize()));
+        // raw anchorFetchEndpoint URL as the request is POST
+        mapResponse(intermediateId + "/fetch", mockResponse(subordinateStatement));
+
+        try {
+            final List<SubordinateStatementContainer> result =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(intermediateId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SubordinateStatement statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertNull(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomSubordinateStatementFilterStrategy.CUSTOM_CLAIM_NAME));
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidSubordinateStatement_customCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("subordinate_crit"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<SubordinateStatementContainer> result =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SubordinateStatement statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertEquals(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomSubordinateStatementFilterStrategy.CUSTOM_CLAIM_NAME),
+                    CustomSubordinateStatementFilterStrategy.CUSTOM_CLAIM_VALUE);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testUnsignedSubordinateStatement()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = new PlainJWT(builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testEncryptedSubordinateStatement()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final SignedJWT signedJwt = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build());
+        final JWEObject jwe = new JWEObject(new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM)
+                    .contentType("JWT")
+                    .build(),
+                new Payload(signedJwt));
+        try {
+            jwe.encrypt(new RSAEncrypter(leafKey.toRSAKey()));
+        } catch (JOSEException e) {
+            Assert.fail("Encryption failed", e);
+        }
+        final String subordinateStatement = jwe.serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testWithUnrecognizedCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("default_crit"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testWithStandardClaimAsCriticalClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("crit", List.of("jwks"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testSignatureWithNonMathchingKey()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testValidClaims_nonMatchingSubject()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String claimsEntityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(claimsEntityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testValidClaims_invalidTypeHeader()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testValidClaims_forbiddenHeader_trustChain()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .type(new JOSEObjectType("entity-statement+jwt"))
+                .keyID(trustedAnchorKey.getKeyID())
+                .customParam("trust_chain", "forbidden")
+                .build();
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, header, builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testValidClaims_forbiddenHeader_peerTrustChain()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
+                .type(new JOSEObjectType("entity-statement+jwt"))
+                .keyID(trustedAnchorKey.getKeyID())
+                .customParam("peer_trust_chain", "forbidden")
+                .build();
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, header, builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testIssuedInFuture()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now().plusSeconds(300)))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testMissingSub()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testExpired()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().minusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testMissingJwks()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testInvalidConstraints_maxPathLength()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("constraints", Map.of("max_path_length", "non_integer"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testInvalidConstraints_namingConstraint()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("constraints", Map.of("naming_constraints", "not_map"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testInvalidConstraints_allowedEntityTypes()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("constraints", Map.of("allowed_entity_types", "not_list"))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testValidConstraints()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String,Object> constraints = new HashMap<>();
+        constraints.put("max_path_length", 5);
+        constraints.put("allowed_entity_types", List.of("openid_relying_party"));
+        constraints.put("naming_constraints", Map.of("permitted", List.of(".example.com")));
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("constraints", constraints)
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<SubordinateStatementContainer> result =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            final SubordinateStatement statement = result.get(0).getStatement();
+            Assert.assertNotNull(statement);
+            assert statement != null;
+            Assert.assertNull(statement.getParsedPayload().getCustomClaims()
+                    .get(CustomSubordinateStatementFilterStrategy.CUSTOM_CLAIM_NAME));
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testForbiddenClaim_trustMark()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final String trustMark = TrustChainTestUtil.trustMark(JWSAlgorithm.RS256, trustMarkIssuerKey, trustMarkIssuerId,
+                entityId, "https://example.org/email-allowing-trust-mark", Instant.now().plusSeconds(300)).serialize();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_marks", List.of(Map.of(
+                        "trust_mark_type", "https://example.org/email-allowing-trust-mark",
+                        "trust_mark", trustMark)))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testForbiddenClaim_trustMarkIssuers()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_issuers", List.of(trustMarkIssuerId))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testForbiddenClaim_trustMarkOwners()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final Map<String,Object> trustMarkOwner = new HashMap<>();
+        trustMarkOwner.put("sub", trustMarkIssuerId);
+        trustMarkOwner.put("jwks", new JWKSet(leafKey).toJSONObject(true));
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("trust_mark_owners", Map.of("https://example.org/email-allowing-trust-mark", trustMarkOwner))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testInvalidJwksClaim()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        assertNoSubordinateStatement(entityId);
+    }
+
+    @Test
+    public void testNullResponse()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        assertNoSubordinateStatement(entityId, false);
+    }
+
+    @Test
+    public void testExceptionResponse()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        when(federationHttpClient.executeOpen(any(),
+                argThat(new RequestUriMatcher(subordinateStatementUrl(anchorFetchEndpoint, entityId))),
+                any())).thenThrow(IOException.class);
+        assertNoSubordinateStatement(entityId, false);
+    }
+
+    protected void assertNoSubordinateStatement(final String entityId) {
+        assertNoSubordinateStatement(entityId, true);
+    }
+
+    protected void assertNoSubordinateStatement(final String entityId, final boolean containerExists) {
+        try {
+            final List<SubordinateStatementContainer> result =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            if (containerExists) {
+                Assert.assertEquals(result.size(), 1);
+                Assert.assertNull(result.get(0).getStatement());
+            } else {
+                Assert.assertEquals(result.size(), 0);
+            }
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+        
+    }
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java
new file mode 100644
index 0000000..d543502
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java
@@ -0,0 +1,274 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.cache;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.AbstractFederationFlowTest;
+import net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.EntityConfigurationFlowTest;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.cache.IssuerEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.ResponseContainerExpirationCriterion;
+import net.shibboleth.oidfed.metadata.cache.SubjectEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.trustchain.TrustChainsContainer;
+import net.shibboleth.oidfed.test.TrustChainTestUtil;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Unit tests for the default entity configuration metadata cache.
+ */
+ at SuppressWarnings("null")
+public class TrustChainMetadataCacheTest extends AbstractFederationFlowTest {
+
+    protected TrustChainMetadataCacheTest() {
+        super(EntityConfigurationFlowTest.FLOW_ID);
+    }
+
+    @Autowired
+    @Qualifier("shibboleth.oidfed.TrustChainMetadataCache")
+    MetadataCache<TrustChainsContainer> trustChainCache;
+
+    @Test
+    public void testValidTrustChainResolved()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("authority_hints", List.of(anchorId));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+
+        final JWTClaimsSet.Builder builder2 = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder2.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<TrustChainsContainer> result =
+                    trustChainCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            Assert.assertNotNull(result.get(0).getTrustChains());
+            Assert.assertEquals(result.get(0).getTrustChains().size(), 1);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test(enabled = false)
+    //TODO: enable once the entity configuration cache can be cleaned before/after the test
+    //otherwise the non-compatible trust anchor configuration may be in the cache
+    public void testTrustAnchorConfigurationSignatureWithUntrustedKey()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("authority_hints", List.of(anchorId));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+
+        final JWTClaimsSet.Builder builder2 = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder2.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId),
+                mockResponse(trustedAnchorConfiguration(null, null, anchorKey, new JWKSet(anchorKey))));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<TrustChainsContainer> result =
+                    trustChainCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            Assert.assertNotNull(result.get(0).getTrustChains());
+            Assert.assertEquals(result.get(0).getTrustChains().size(), 0);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test(enabled = false)
+    //TODO: enable once the trust anchor cache can be cleaned before/after the test
+    //otherwise the non-compatible trust anchor configuration may be in the cache
+    public void testTrustAnchorSubordinateSignatureWithUntrustedKey()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("authority_hints", List.of(anchorId));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+
+        final JWTClaimsSet.Builder builder2 = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, anchorKey, "entity-statement+jwt", builder2.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId),
+                mockResponse(trustedAnchorConfiguration(null, null, trustedAnchorKey,
+                        new JWKSet(List.of(trustedAnchorKey, anchorKey)))));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<TrustChainsContainer> result =
+                    trustChainCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            Assert.assertNotNull(result.get(0).getTrustChains());
+            Assert.assertEquals(result.get(0).getTrustChains().size(), 0);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidTrustChainResolved_intermediateWithBrokenAuthorityHint()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("authority_hints", List.of(trustedIntermediateId));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+        mapResponse(entityConfigurationUrl(trustedIntermediateId),
+                mockResponse(intermediateConfiguration(trustedIntermediateId,
+                new JWKSet(List.of(intermediateKey, trustedIntermediateKey)), trustedIntermediateKey)));
+
+        final JWTClaimsSet.Builder builder2 = new JWTClaimsSet.Builder().issuer(trustedIntermediateId)
+                .subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedIntermediateKey, "entity-statement+jwt", builder2.build()).serialize();
+
+        mapResponse(subordinateStatementUrl(trustedIntermediateId + "/fetch", entityId),
+                mockResponse(subordinateStatement));
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+
+        try {
+            final List<TrustChainsContainer> result =
+                    trustChainCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 1);
+            Assert.assertNotNull(result.get(0).getTrustChains());
+            Assert.assertEquals(result.get(0).getTrustChains().size(), 1);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+    @Test
+    public void testValidTrustChainResolved_customFilterReturnsNull()
+            throws MetadataCacheException, UnsupportedOperationException, IOException, URISyntaxException {
+        final String entityId = uniqueClientId();
+        
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder().issuer(entityId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()))
+                .claim("crit", List.of("default_crit"))
+                .claim("authority_hints", List.of(anchorId));
+        final String entityConfiguration = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, leafKey, "entity-statement+jwt", builder.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(entityId), mockResponse(entityConfiguration));
+
+        final JWTClaimsSet.Builder builder2 = new JWTClaimsSet.Builder().issuer(anchorId).subject(entityId)
+                .issueTime(Date.from(Instant.now()))
+                .expirationTime(Date.from(Instant.now().plusSeconds(300)))
+                .claim("jwks", new JWKSet(leafKey).toJSONObject(true))
+                .claim("metadata", Map.of("federation_entity", Collections.emptyMap()));
+        final String subordinateStatement = TrustChainTestUtil.signedJwt(
+                JWSAlgorithm.RS256, trustedAnchorKey, "entity-statement+jwt", builder2.build()).serialize();
+
+        mapResponse(entityConfigurationUrl(anchorId), mockResponse(trustedAnchorConfiguration()));
+        mapResponse(subordinateStatementUrl(anchorFetchEndpoint, entityId),
+                mockResponse(subordinateStatement));
+        try {
+            final List<TrustChainsContainer> result =
+                    trustChainCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new ResponseContainerExpirationCriterion(Instant.now().plusSeconds(300)), 
+                            new IssuerEntityIDCriterion(anchorId)));
+            Assert.assertNotNull(result);
+            Assert.assertEquals(result.size(), 0);
+        } catch (MetadataCacheException e) {
+            Assert.fail("Could not resolve entity configuration", e);
+        }
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationFilterStrategy.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationFilterStrategy.java
new file mode 100644
index 0000000..040a963
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationFilterStrategy.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.function.BiFunction;
+
+import net.shibboleth.oidc.metadata.filter.MetadataFilterContext;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.cache.configuration.EntityConfigurationContainer;
+import net.shibboleth.oidfed.metadata.impl.EntityConfigurationImpl;
+import net.shibboleth.oidfed.metadata.payload.impl.EntityConfigurationPayloadImpl;
+
+/**
+ * Custom filter strategy for entity configuration container.
+ */
+public class CustomEntityConfigurationFilterStrategy implements 
+    BiFunction<EntityConfigurationContainer, MetadataFilterContext, EntityConfigurationContainer>{
+
+    public static final String CUSTOM_CLAIM_NAME = "custom_claim";
+    public static final String CUSTOM_CLAIM_VALUE = "custom_value";
+
+    /** {@inheritDoc} */
+    @Override
+    public EntityConfigurationContainer apply(final EntityConfigurationContainer container,
+            final MetadataFilterContext context) {
+        final EntityConfiguration statement = container.getStatement();
+        if (statement == null) {
+            return null;
+        }
+        if (Optional.ofNullable(statement.getParsedPayload().getCritical())
+                .map(list -> list.contains("default_crit"))
+                .orElse(false)) {
+            final EntityConfigurationPayloadImpl payload =
+                    new EntityConfigurationPayloadImpl(statement.getParsedPayload());
+            payload.setCustomClaims(CUSTOM_CLAIM_NAME, CUSTOM_CLAIM_VALUE);
+            final Instant expiration = Instant.now().plusSeconds(300);
+            assert expiration != null;
+            return new EntityConfigurationContainer(statement.getSubject(),
+                    new EntityConfigurationImpl(statement.getJwt(), payload), expiration, expiration);
+        }
+        return container;
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationMetadataDecorator.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationMetadataDecorator.java
new file mode 100644
index 0000000..cc9d43a
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomEntityConfigurationMetadataDecorator.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support;
+
+import java.util.Map;
+
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecorator;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.ConstraintViolationException;
+
+public class CustomEntityConfigurationMetadataDecorator extends AbstractIdentifiableInitializableComponent
+    implements EntityConfigurationMetadataDecorator {
+
+    public final static String CUSTOM_ENTITY_TYPE = "custom_entity_type";
+
+    /** {@inheritDoc} */
+    @Override
+    public void accept(@Nullable final Map<String, Map<String, Object>> metadata,
+            @Nullable final ProfileRequestContext profileRequestContext) {
+        if (metadata == null) {
+            throw new ConstraintViolationException("Metadata cannot be null");
+        }
+        if (metadata.containsKey(CUSTOM_ENTITY_TYPE)) {
+            metadata.get(CUSTOM_ENTITY_TYPE).putAll(Map.of("key1", "value1", "key2", "value2"));
+        } else {
+            metadata.put(CUSTOM_ENTITY_TYPE, Map.of("key1", "value1", "key2", "value2"));
+        }
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomSubordinateStatementFilterStrategy.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomSubordinateStatementFilterStrategy.java
new file mode 100644
index 0000000..a5396cf
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomSubordinateStatementFilterStrategy.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.function.BiFunction;
+
+import net.shibboleth.oidc.metadata.filter.MetadataFilterContext;
+import net.shibboleth.oidfed.metadata.SubordinateStatement;
+import net.shibboleth.oidfed.metadata.cache.subordinate.SubordinateStatementContainer;
+import net.shibboleth.oidfed.metadata.impl.SubordinateStatementImpl;
+import net.shibboleth.oidfed.metadata.payload.impl.SubordinateStatementPayloadImpl;
+
+/**
+ * Custom filter strategy for subordinate statement container.
+ */
+public class CustomSubordinateStatementFilterStrategy implements 
+    BiFunction<SubordinateStatementContainer, MetadataFilterContext, SubordinateStatementContainer>{
+
+    public static final String CUSTOM_CLAIM_NAME = "custom_so_claim";
+    public static final String CUSTOM_CLAIM_VALUE = "custom_so_value";
+
+    /** {@inheritDoc} */
+    @Override
+    public SubordinateStatementContainer apply(final SubordinateStatementContainer container,
+            final MetadataFilterContext context) {
+        final SubordinateStatement statement = container.getStatement();
+        if (statement == null) {
+            return null;
+        }
+        if (Optional.ofNullable(statement.getParsedPayload().getCritical())
+                .map(list -> list.contains("subordinate_crit"))
+                .orElse(false)) {
+            final SubordinateStatementPayloadImpl payload =
+                    new SubordinateStatementPayloadImpl(statement.getParsedPayload());
+            payload.setCustomClaims(CUSTOM_CLAIM_NAME, CUSTOM_CLAIM_VALUE);
+            final Instant expiration = Instant.now().plusSeconds(300);
+            assert expiration != null;
+            return new SubordinateStatementContainer(container.getIdentifier(),
+                    new SubordinateStatementImpl(statement.getJwt(), payload), expiration, expiration);
+        }
+        return container;
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomTrustChainFilterStrategy.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomTrustChainFilterStrategy.java
new file mode 100644
index 0000000..d4bbe65
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/support/CustomTrustChainFilterStrategy.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed 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 net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.function.BiFunction;
+
+import net.shibboleth.oidc.metadata.filter.MetadataFilterContext;
+import net.shibboleth.oidfed.metadata.EntityStatement;
+import net.shibboleth.oidfed.metadata.cache.trustchain.TrustChainsContainer;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * Custom filter strategy for trust chains container.
+ */
+public class CustomTrustChainFilterStrategy implements 
+    BiFunction<TrustChainsContainer, MetadataFilterContext, TrustChainsContainer>{
+
+    /** {@inheritDoc} */
+    @Override
+    public TrustChainsContainer apply(final TrustChainsContainer container,
+            final MetadataFilterContext context) {
+        final List<List<EntityStatement<?>>> trustChains =
+                Optional.ofNullable(container.getTrustChains()).orElse(CollectionSupport.emptyList());
+        for (final List<EntityStatement<?>> trustChain : trustChains) {
+            final List<String> critical = trustChain.get(0).getParsedPayload().getCritical();
+            if (critical != null && critical.contains("default_crit")) {
+                return null;
+            }
+        }
+        return container;
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/test/TrustChainTestUtil.java b/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/test/TrustChainTestUtil.java
new file mode 100644
index 0000000..833f26f
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/java/net/shibboleth/oidfed/test/TrustChainTestUtil.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.test;
+
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+
+import org.testng.Assert;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.module.SimpleAbstractTypeResolver;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JOSEObjectType;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.ECDSASigner;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.oidc.metadata.policy.MetadataPolicy;
+import net.shibboleth.oidfed.metadata.EntityStatement;
+import net.shibboleth.oidfed.metadata.impl.EntityConfigurationImpl;
+import net.shibboleth.oidfed.metadata.impl.SubordinateStatementImpl;
+import net.shibboleth.oidfed.metadata.jackson.InstantDeserializer;
+import net.shibboleth.oidfed.metadata.jackson.JWKSetDeserializer;
+import net.shibboleth.oidfed.metadata.payload.claim.Metadata;
+import net.shibboleth.oidfed.metadata.payload.claim.impl.MetadataImpl;
+import net.shibboleth.oidfed.metadata.policy.FederationMetadataPolicyDeserializer;
+
+/**
+ * Various utility methods for testing trust chains.
+ */
+public class TrustChainTestUtil {
+
+    public static List<EntityStatement<?>> chainWithIntermediate(final EntityStatement<?> leaf, final String anchorId,
+            final String intermediateId) {
+        try {
+            final RSAKey anchorKey = new RSAKeyGenerator(2048)
+                    .keyID("mockTrustAnchorKey")
+                    .keyUse(KeyUse.SIGNATURE)
+                    .generate();
+            final RSAKey intermediateKey = new RSAKeyGenerator(2048)
+                    .keyID("mockIntermediateKey")
+                    .keyUse(KeyUse.SIGNATURE)
+                    .generate();
+            final EntityStatement<?> trustAnchor = trustAnchor(JWSAlgorithm.RS256, anchorKey, anchorId);
+            final EntityStatement<?> intermediateStatement = entityStatement(JWSAlgorithm.RS256, anchorKey,
+                    new JWTClaimsSet.Builder()
+                    .subject(intermediateId)
+                    .issueTime(new Date())
+                    .issuer(anchorId)
+                    .build());
+            final EntityStatement<?> leafSubordinateStatement = entityStatement(JWSAlgorithm.RS256, intermediateKey,
+                    new JWTClaimsSet.Builder(leaf.getJwt().getJWTClaimsSet())
+                    .subject(leaf.getSubject())
+                    .issueTime(new Date())
+                    .issuer(intermediateId)
+                    .build());
+            return List.of(leaf, leafSubordinateStatement, intermediateStatement, trustAnchor);
+        } catch (final  JOSEException | ParseException e) {
+            Assert.fail("Could not construct trust chain", e);
+        }
+        return null;
+    }
+
+    public static EntityStatement<?> trustAnchor(final JWSAlgorithm algorithm, final JWK jwk, final String entityId) {
+        return entityStatement(algorithm, jwk, 
+                new JWTClaimsSet.Builder()
+                .subject(entityId)
+                .issueTime(new Date())
+                .issuer(entityId)
+                .build());
+    }
+
+    public static EntityStatement<?> entityStatement(final JWSAlgorithm algorithm, final JWK jwk,
+            final JWTClaimsSet claimsSet) {
+        return entityStatement(signedJwt(algorithm, jwk, "entity-statement+jwt", claimsSet));
+    }
+
+    public static EntityStatement<?> entityStatement(final SignedJWT jwt) {
+        try {
+            if (jwt.getJWTClaimsSet().getSubject().equals(jwt.getJWTClaimsSet().getIssuer())) {
+                return EntityConfigurationImpl.parse(jwt, payloadObjectMapper());
+            }
+            return SubordinateStatementImpl.parse(jwt, payloadObjectMapper());
+        } catch (JsonProcessingException | ParseException e) {
+            Assert.fail("Could not construct entity configuration", e);
+        }
+        return null;
+        
+    }
+
+    @Nonnull public static ObjectMapper payloadObjectMapper() {
+        final SimpleModule jacksonModule = new SimpleModule();
+        jacksonModule.addDeserializer(MetadataPolicy.class, new FederationMetadataPolicyDeserializer("scope"));
+        jacksonModule.addDeserializer(JWKSet.class, new JWKSetDeserializer());
+        jacksonModule.addDeserializer(Instant.class, new InstantDeserializer());
+        final SimpleAbstractTypeResolver jacksonResolver = new SimpleAbstractTypeResolver();
+        jacksonResolver.addMapping(Metadata.class, MetadataImpl.class);
+        jacksonModule.setAbstractTypes(jacksonResolver);
+        final ObjectMapper objectMapper = new ObjectMapper();
+        objectMapper.registerModule(jacksonModule);
+        return objectMapper;
+    }
+
+    public static SignedJWT trustMark(final JWSAlgorithm algorithm, final JWK jwk, final String iss, final String sub,
+            final String id, final Instant exp) {
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+                .subject(sub)
+                .issuer(iss)
+                .claim("trust_mark_type", id)
+                .issueTime(new Date())
+                .expirationTime(exp == null ? null : Date.from(exp));
+        return signedJwt(algorithm, jwk, "trust-mark+jwt", builder.build());
+    }
+
+    public static SignedJWT signedJwt(final JWSAlgorithm algorithm, final JWK jwk, final String type,
+            final JWTClaimsSet claimsSet) {
+        return signedJwt(algorithm, jwk,
+                new JWSHeader.Builder(algorithm).type(new JOSEObjectType(type)).keyID(jwk.getKeyID()).build(),
+                claimsSet);
+    }
+
+    public static SignedJWT signedJwt(final JWSAlgorithm algorithm, final JWK jwk, final JWSHeader header,
+            final JWTClaimsSet claimsSet) {
+        final SignedJWT signedJwt = new SignedJWT(header, claimsSet);
+        try {
+            if (JWSAlgorithm.Family.RSA.contains(algorithm)) {
+                signedJwt.sign(new RSASSASigner(jwk.toRSAKey()));
+            } else {
+                signedJwt.sign(new ECDSASigner(jwk.toECKey()));
+            }
+            return signedJwt;
+        } catch (final JOSEException e) {
+            Assert.fail("Could not construct signed JWT", e);
+        }
+        return null;
+    }
+
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-anchor.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-anchor.jwk
new file mode 100644
index 0000000..8601a78
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-anchor.jwk
@@ -0,0 +1,13 @@
+{
+    "alg": "RS256",
+    "kid": "locallyTrustedAnchorKey",
+    "kty": "RSA",
+    "p": "y_ul6_wF0dnPzfK_BW0q5dM2aKRFOekotB6mFmWXE4VY_EVRpPAcHQppBbOUOBMkg_NSmjRK3x8wZYhLeavqfsFNcpGMAMsL1LA_FtPFo1R2B8n-FpcA9uRimjDxlzIQKrbEYM1OGPA7jLCE57fNzta2YM4e1sjP3W7-TfOa7v0",
+    "q": "x--fmMMzN9CRakq-pi6I0ClQq4PXJnJROGzVSjMGKs-YbaFXaWPnF_rHqJunmFavTiiSwCv36kiseEAA8fTl-Ms75MXp7LGo30e0F7JJ3vXDD5MwvapswwoiWJWCgJxCOnWJdWvm_iZApK9xcaM_ngTWQuQEq1JhPq6rEB4wck0",
+    "d": "AnBEysAXveM6Lkt2U4RaRoq30upD1EwX7GBE0xCEyzXHESoZIhriDICL9DcCnH7wz0GHittysTrnzEcOWA37mMRKtMJ9ZX3P4QXhfcxGPVvJoDXuH3hR1rbXsu-b9-aYsGNeO6fZSafqhXBeXYU3q1CJFFjoe72drw3DJ3xwCRdYv3y94YBw09kAQYOA4zAtxrUF3ZhlLH9K4sfFLAEzztJX-OuY3a2UFeNC1FkpNDKovbXU0yrkP_y5jjSPrjI2lBQg4yFN5hM3YM7bvG001E796CEhqRYsnLazNDpRXAIQc_jbs_3YrdrlaWR-abMj1zr0zw44a8JZ5Tvd7imZaQ",
+    "e": "AQAB",
+    "qi": "OlB4Yfvc6MBNsFUPpFGXmuYjVDkgTtcAEG60n1TDsrh4C2ir0-ZyaQg1gV3D3EabD96lnp7IiO3uX6X6e6AJgC2AW052bc1EuRQkhyyXeEYN23u3elF1y2F4eQUbNgfc2ghZfUqx5xhZDWFXbrMC7NaOCn1UOIfBe2kuvqSL8Es",
+    "dp": "ebkddgjaYDOd8cPdgZt3cdXsLd15AenExFdVvR-6W4fDZibnZYly_VFtAl37IMsriyH0NNjnpOWzt6LxhxWzxRgM40U_SmngEXdq7nBJDAImvNcorMpHZQ08Wc7DG_pf811FKo7Y_8C7iGT9qljgk4FFK9dUR89lWzoUvueTmPE",
+    "dq": "UBHQ8pbJ_kJS2iSQ8XCVbff9zJKCKW2CxXwgdxS0FZUJ0G3a2eQeemX-a7HajpG4py5shvWU1YjBOW84ca3II7kQhXAVXKtRnAnVP-Aw4U-_DI-_51VHNVzroFpP5z2s8Eh-Aj5yRboADXQNlJryMVBylltG222kcDv3Wf8dG8k",
+    "n": "n0-NFV06ZDKLo1v8KrSJsQ8bbLEffVJw1F5jGXqrKh_4PpBt9FmyWY3gIA9aK1p1WneMaWRNlM1EObierCr0EdXCQbgpKorrPqxiwyl6cOMIH4fN_9uWGqD2HlyGcjcESrNjZz75tNr_9oegh6fWSMgrxyySpU38ALWUX1ZuNS8A4tj8XdJSbSHqftf7qOdgzuy0yaD5h7NwoBCRPOIY88vOLHkcQ4nYdkk8GLSIf5GgGb7JFiPuFHN7pK---LNnFBifag2wbEZ9nnAcAol4jc2gF7zq2mqhMSlbIVmTRj4Y9wxh3DPbmC8xZ-8nbhPmgi4vlij9JWJGEvfLuXaMGQ"
+}
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-intermediate.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-intermediate.jwk
new file mode 100644
index 0000000..7ba5321
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-local-intermediate.jwk
@@ -0,0 +1,12 @@
+{
+    "p":"vGxtVhQO5fnZ3FRA48ECw8y0Oojketpf0kM8dtTZpao3BYR5xZDJ7tt8ebGCpRfMWmy48PJqIR2-hAAe_i6GnX9mmlIx_fkur0aoMAx-M1kNHY3Dkr_NL6ulBUBj0b-1G_36a4BoLybflhNaFxmL2mznqUgELOPbu5oDyTyCqMk",
+    "kty":"RSA",
+    "q":"xbJas9_2ntoPXuLNX-MUFj6uxmuU1atmHO6VtC6tL7wp3uuHq1QuPiBt6Ik0Emh0ShuES9mrnXrBbc8vVw9Z_HeeGrW6t26tYN10mNXJsO69_nOiN1IL1Sb_sPRsYcxPi02gZAPKxXcqg4IfKY2JGh5Z-_UukGjUlPVENK3eM-s",
+    "d":"JGCJMehmnZScKLpK2IXtRsGGIbSvb0CG5kP8L8w-CN-K6FlbeKRGqHyNY7fojVjnbi-hR5O_TmFvhXmwgJOwGfX550HRU3sMXyi7WhAXzYfoJQivElFqUosWForIETHaU5mhZGpT-emIcxgOpPUw_3zGdUzzGFhxn4IIFEELX0C5JzZqi8fAx-d3UCp7bHJKgh8qF_agkXnbU4aHdHpq9NEuATTY8U9Qos6clGfAK3BlRMnb5I-uFGOyrbjUYnbDVyQi26yUhJ6yQtttFGWam6yDAjpLFJYXpGdcbf4ibGPtl-32b7eDRuQsOqSU_kXBktT_8DlIZ1n-U7BrhrQB",
+    "e":"AQAB",
+    "kid":"locallyTrustedIntermediateKey",
+    "qi":"l1mhiM0lvZMZongGLwv9r-GOzu1_LhYxq3KBXi_BHgoM7DR3aVGyCEOthFQTNnKb2laALaxVyqrTGGl4zaPGani1D7YzvIWOk4610U1V7Tykr86H7geud7PC8stF_XzwDwx9pYu-sMH7EdmTPQQPo3OQdHECEoVHD36Gp609RUo",
+    "dp":"FV-PP3ZjAj9HMTD1c2BPefpcb09b63ud5vHth-U5Ewut1hhi38A-x7Np-Tvjf-qlKZSvndVBqKQBGmQRH_ATIQZ0kwjD1vVPEF7JcTnZjuWJEVMlXh4XnlKwE1pseDxwxM4Ye91C9CZKsnFbhMdHD-3OkWsz3guyvyMHTupoP9E",
+    "dq":"BArNh_gAVucmgU4p3NgLxRirAiuY83V5tQW70d9SczNpt8EhGQOznlmTZbnIcsfn-MvvPI3K0IF-Cvy9fw41TNA1T15_3thIez6L78QPR6rZ_6XlnzPyQf12JOwaezVuOu7vZJwfPUJegCnc8UCPmRUdyeUNeq5qClGpf1o-vNk",
+    "n":"kYK2ScZiF-zm9VnOS0MXMrszjOtEj7_YmyuJzyG3VHZPH5EPR_79MwzXgRUxFQXTHFw93NQbiIL3m6TX6RDCtphPdO7iVTF_aNRqCy3Hdn7Ws8NQbAHyj9WihoN6b-euFc_T-C7hkPYJcoIG9A9X0rcXyE2hlS4n386eWSR-gTHjvoZQ_M63Eoo4FZVx_8213Qmx0Pwypi-iZ1mW745TI1FvuYzZfZk0xGdfxsgJzQLnXPm_hcQJKMcOFPxdqw0xLqKdByp2LKjrqlOIoZKgTOeWvFr_TV21vKC5ijp1Nh4POwkI-urAl6fmELx_Mn_z7ZvwUhefzznnRgL6xt77gw"
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es256.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es256.jwk
new file mode 100644
index 0000000..a70c96c
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es256.jwk
@@ -0,0 +1,10 @@
+{
+  "kty": "EC",
+  "d": "CO-ctmQcB-hS042i2omOIPpaaAaKkBAU6s_v4W09oA0",
+  "use": "sig",
+  "crv": "P-256",
+  "kid": "fedtestkeyES256",
+  "x": "2uzfE1oK0cf1_c11SFc9vFdGLnJoH3e0AKTrGPAmUis",
+  "y": "14410NGKqwLM58b26ZcvGOruFixpHt_SJTw8I5wwgLQ",
+  "alg": "ES256"
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es384.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es384.jwk
new file mode 100644
index 0000000..c42f913
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es384.jwk
@@ -0,0 +1,9 @@
+{
+  "kty": "EC",
+  "d": "e65hCxxbNq5gubmkgZD73A1cDf_GfGzkl4KZtbRg0GxAktztyDg4pI4bcxXaUNOb",
+  "use": "sig",
+  "crv": "P-384",
+  "kid": "fedtestkeyES384",
+  "x": "uVsAjiFw4Hv0Kcwl2532baUKPTzDht2966ar_pJ8ZdAzquFwJPdRjCfpbkqZUi46",
+  "y": "yp3W3Cmc1QQptLC3s072Iy69l1ubx_WSFRivMYqCpK4Ec89HKvYh3mTKcfjHvk2l"
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es521.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es521.jwk
new file mode 100644
index 0000000..b42d56a
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-es521.jwk
@@ -0,0 +1,9 @@
+{
+  "kty": "EC",
+  "d": "ADaJK1sgPtlu4xAFGmb8scq8XGujamVjP3z7Xr4xErwuurSynn8sNtZKX8SfoId9syS27VLFHe12CbeBR6nbReFv",
+  "use": "sig",
+  "crv": "P-521",
+  "kid": "fedtestkeyES512",
+  "x": "AKObj9VTXWndDB7RC9dqSEkEsCqYgOHxq9AgvlDA8XBKxPzp39XrnBD0CMFy0C1HFvoiFKh9lPXJewkkruAOLW-6",
+  "y": "AMG6cRDBekWfD8imLDkBCmm-mtI16mFbifxZ06bgI5GwdyRTIMYUaBizmOzRK038Am4h6EjF8RCFr7383iKcqGZt"
+}
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-rs.jwk b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-rs.jwk
new file mode 100644
index 0000000..a6297ee
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/credentials/fed-signing-rs.jwk
@@ -0,0 +1,8 @@
+{
+  "kty": "RSA",
+  "d": "gv7aqFcXV86jDcCn6-JCqEEIRcv1Rh1AEv4dKziFzQal1nROliDdtkJjELpOYlFY9CgI-xAXt8ivwJ4q1eA_G9WTId7qLxPdcQW4QjfRl8VVEPUhka6Gc8y95WUO4VONEwzZnZ4V7KobE0QGADXvXUw3MtIZdGgvRCS-6avQXITjhTnlkUONxeqpy2BE6l0cI8GSM1vlLy66vjsQ06aAizMB-g3yMMpbKNd73oYgrdpEjAtddH3-sLhv_TG7pMlbB_etnPGkWKdIbpvTKr2P2oZN_8Qvq7G4ETIe9nIv7i8T7GXZfTxWspYkszbrpRACM9Ic8fSctvil2j013JeSgQ",
+  "e": "AQAB",
+  "use": "sig",
+  "kid": "fedtestkeyRS",
+  "n": "pNf03ghVzMAw5sWrwDAMAZdSYNY2q7OVlxMInljMgz8XB5mf8XKH3EtP7AKrb8IAf7rGhfuH3T1N1C7F-jwIeYjXxMm2nIAZ0hXApgbccvBpf4n2H7IZflMjt4A3tt587QQSxQ069drCP4sYevxhTcLplJy6RWA0cLj-5CHyWy94zPeeA4GRd6xgHFLz0RNiSF0pF0kE4rmRgQVZ-b4_BmD9SsWnIpwhms5Ihciw36WyAGQUeZqULGsfwAMwlNLIaTCBLAoRgv370p-XsLrgz86pTkNBJqXP5GwI-ZfgiLmJuHjQ9l85KqHM87f-QdsqiV8KoRcslgXPqb6VOTJBVw"
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/credentials.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/credentials.xml
new file mode 100644
index 0000000..89e39c6
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/credentials.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+       xmlns:context="http://www.springframework.org/schema/context"
+       xmlns:util="http://www.springframework.org/schema/util"
+       xmlns:p="http://www.springframework.org/schema/p"
+       xmlns:c="http://www.springframework.org/schema/c"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+                           
+       default-init-method="initialize"
+       default-destroy-method="destroy">
+       
+    <import resource="oidfed/oidfed-credentials.xml" />
+
+    <!--
+    NOTE: if you're using a legacy relying-party.xml file from a V2 configuration, this file is ignored.
+
+    This defines the signing and encryption key and certificate pairs referenced by your relying-party.xml
+    configuration. You don't normally need to touch this, unless you have advanced requirements such as
+    supporting multiple sets of keys for different relying parties, in which case you may want to define
+    all your credentials here for convenience.
+    -->
+
+    <!--
+    The list of ALL of your IdP's signing credentials. If you define additional signing credentials,
+    for example for specific relying parties or different key types, make sure to include them within this list.
+    -->
+    <util:list id="shibboleth.SigningCredentials">
+        <ref bean="shibboleth.DefaultSigningCredential" />
+    </util:list>
+    
+    <!-- Your IdP's default signing key, set via property file. -->
+    <bean id="shibboleth.DefaultSigningCredential"
+        class="net.shibboleth.idp.profile.spring.factory.BasicX509CredentialFactoryBean"
+        p:privateKeyResource="%{idp.signing.key}"
+        p:certificateResource="%{idp.signing.cert}"
+        p:entityId-ref="entityID" />
+        
+    <!-- Your IdP's default client TLS credential, by default the same as the default signing credential. -->
+    <alias alias="shibboleth.DefaultClientTLSCredential" name="shibboleth.DefaultSigningCredential" />
+    
+    <!--
+    The list of ALL of your IdP's encryption credentials. By default this is just an alias
+    for 'shibboleth.DefaultEncryptionCredentials'. It could be re-defined as
+    a list with additional credentials if needed.
+    -->
+    <alias alias="shibboleth.EncryptionCredentials" name="shibboleth.DefaultEncryptionCredentials" />
+        
+    <!-- Your IdP's default encryption (really decryption) keys, set via property file. -->
+    <util:list id="shibboleth.DefaultEncryptionCredentials">
+        <bean class="net.shibboleth.idp.profile.spring.factory.BasicX509CredentialFactoryBean"
+            p:privateKeyResource="%{idp.encryption.key}"
+            p:certificateResource="%{idp.encryption.cert}"
+            p:entityId-ref="entityID" />
+
+        <!--
+        For key rollover, uncomment and point to your original keypair, and use the one above
+        to point to your new keypair. Once metadata has propagated, comment this one out again.
+        -->
+        <!--
+        <bean class="net.shibboleth.idp.profile.spring.factory.BasicX509CredentialFactoryBean"
+            p:privateKeyResource="%{idp.encryption.key.2}"
+            p:certificateResource="%{idp.encryption.cert.2}"
+            p:entityId-ref="entityID" />
+        -->
+    </util:list>
+
+</beans>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml
new file mode 100644
index 0000000..d6f592e
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/global.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+       xmlns:context="http://www.springframework.org/schema/context"
+       xmlns:util="http://www.springframework.org/schema/util"
+       xmlns:p="http://www.springframework.org/schema/p"
+       xmlns:c="http://www.springframework.org/schema/c"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+                           
+       default-init-method="initialize"
+       default-destroy-method="destroy">
+
+    <bean id="CustomEntityConfigurationMetadataDecorator"
+        class="net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomEntityConfigurationMetadataDecorator" />
+
+    <bean id="MockitoMockFactory" class="org.mockito.Mockito" />
+
+    <bean id="shibboleth.oidfed.HttpClient"
+        factory-bean="MockitoMockFactory"
+        factory-method="mock">
+        <constructor-arg value="#{T(org.apache.hc.client5.http.classic.HttpClient)}" />
+    </bean>
+
+    <bean id="shibboleth.oidc.NonBrowser.HttpClient"
+        factory-bean="MockitoMockFactory"
+        factory-method="mock">
+        <constructor-arg value="#{T(org.apache.hc.client5.http.classic.HttpClient)}" />
+    </bean>
+
+    <util:list id="CustomEntityConfigurationFilters">
+        <bean class="net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomEntityConfigurationFilterStrategy"/>
+    </util:list>
+
+    <util:list id="CustomSubordinateStatementFilters">
+        <bean class="net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomSubordinateStatementFilterStrategy"/>
+    </util:list>
+
+    <util:list id="CustomTrustChainFilters">
+        <bean class="net.shibboleth.idp.plugin.oidc.op.profile.flow.oidfed.support.CustomTrustChainFilterStrategy"/>
+    </util:list>
+
+    <util:set id="testbed.MetadataIndexes">
+        <bean class="org.opensaml.saml.metadata.resolver.index.impl.SAMLArtifactMetadataIndex" />
+    </util:set>
+
+    <util:list id="testbed.MetadataResolverResources">
+        <value>%{idp.home}/conf/metadata-providers.xml</value>
+        <value>%{idp.home}/conf/metadata-filters.xml</value>
+    </util:list>
+
+</beans>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/idp.properties b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/idp.properties
new file mode 100644
index 0000000..07392d5
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/idp.properties
@@ -0,0 +1,210 @@
+# Auto-load all files matching conf/**/*.properties
+# Disable if you want to manually maintain a list of sources.
+#idp.searchForProperties = false
+
+# Load any additional property resources from a comma-delimited list
+idp.additionalProperties = /conf/ldap.properties, \
+    /conf/saml-nameid.properties, \
+    /conf/services.properties, \
+    /conf/admin/admin.properties, \
+    /conf/authn/authn.properties, \
+    /conf/authn/duo.properties, \
+    /conf/oidc.properties, \
+    /credentials/secrets.properties, \
+    /conf/oidfed/oidfed.properties
+
+# In most cases (and unless noted in the surrounding comments) the
+# commented settings in the distributed files are the default
+# behavior for V3. Uncomment them and change the value to change
+# functionality.
+#
+# Uncommented properties are either required or ship non-defaulted.
+
+# Set the entityID of the IdP
+idp.entityID = https://idp.example.org
+
+# Set the file path which backs the IdP's own metadata publishing endpoint at /shibboleth.
+# Set to empty value to disable and return a 404.
+#idp.entityID.metadataFile=%{idp.home}/metadata/idp-metadata.xml
+
+# Set the scope used in the attribute resolver for scoped attributes 
+idp.scope = example.org
+
+# General cookie properties (maxAge only applies to persistent cookies)
+# Note the default for idp.cookie.secure, you will usually want it set.
+#idp.cookie.secure = false
+#idp.cookie.httpOnly = true
+#idp.cookie.domain =
+#idp.cookie.path =
+#idp.cookie.maxAge = 31536000
+
+# HSTS/CSP response headers
+#idp.hsts = max-age=0
+# X-Frame-Options value, set to DENY or SAMEORIGIN to block framing
+#idp.frameoptions = DENY
+# Content-Security-Policy value, set to match X-Frame-Options default
+#idp.csp = frame-ancestors 'none';
+
+# Set the location of user-supplied web flow definitions
+#idp.webflows = %{idp.home}/flows
+
+# Set the location of Velocity view templates
+#idp.views = %{idp.home}/views
+
+# Settings for internal AES encryption key
+#idp.sealer.storeType = JCEKS
+#idp.sealer.updateInterval = PT15M
+#idp.sealer.aliasBase = secret
+idp.sealer.storeResource = %{idp.home}/credentials/sealer.jks
+idp.sealer.versionResource = %{idp.home}/credentials/sealer.kver
+idp.sealer.storePassword = password
+idp.sealer.keyPassword = password
+
+# Settings for public/private signing and encryption key(s)
+# During decryption key rollover, point the ".2" properties at a second
+# keypair, uncomment in credentials.xml, then publish it in your metadata.
+idp.signing.key = %{idp.home}/credentials/idp-signing.key
+idp.signing.cert = %{idp.home}/credentials/idp-signing.crt
+idp.encryption.key = %{idp.home}/credentials/idp-encryption.key
+idp.encryption.cert = %{idp.home}/credentials/idp-encryption.crt
+#idp.encryption.key.2 = %{idp.home}/credentials/idp-encryption-old.key
+#idp.encryption.cert.2 = %{idp.home}/credentials/idp-encryption-old.crt
+
+# Sets the bean ID to use as a default security configuration set
+#idp.security.config = shibboleth.DefaultSecurityConfiguration
+
+# To downgrade to SHA-1, set to shibboleth.SigningConfiguration.SHA1
+#idp.signing.config = shibboleth.SigningConfiguration.SHA256
+
+# To upgrade to AES-GCM encryption, set to shibboleth.EncryptionConfiguration.GCM
+# This is unlikely to work for all SPs, but this is a quick way to test them.
+#idp.encryption.config = shibboleth.EncryptionConfiguration.CBC
+
+# Configures trust evaluation of keys used by services at runtime
+# Defaults to supporting both explicit key and PKIX using SAML metadata.
+#idp.trust.signatures = shibboleth.ChainingSignatureTrustEngine
+# To pick only one set to one of:
+#   shibboleth.ExplicitKeySignatureTrustEngine, shibboleth.PKIXSignatureTrustEngine
+#idp.trust.certificates = shibboleth.ChainingX509TrustEngine
+# To pick only one set to one of:
+#   shibboleth.ExplicitKeyX509TrustEngine, shibboleth.PKIXX509TrustEngine
+
+# If true, encryption will happen whenever a key to use can be located, but
+# failure to encrypt won't result in request failure.
+#idp.encryption.optional = false
+
+# Configuration of client- and server-side storage plugins
+#idp.storage.cleanupInterval = PT10M
+idp.storage.htmlLocalStorage = true
+
+# Set to true to expose more detailed errors in responses to SPs
+#idp.errors.detailed = false
+# Set to false to skip signing of SAML response messages that signal errors
+#idp.errors.signed = true
+# Name of bean containing a list of Java exception classes to ignore
+#idp.errors.excludedExceptions = ExceptionClassListBean
+# Name of bean containing a property set mapping exception names to views
+#idp.errors.exceptionMappings = ExceptionToViewPropertyBean
+# Set if a different default view name for events and exceptions is needed
+#idp.errors.defaultView = error
+
+# Set to false to disable the IdP session layer
+#idp.session.enabled = true
+
+# Set to "shibboleth.StorageService" for server-side storage of user sessions
+idp.session.StorageService = shibboleth.StorageService
+
+# Size of session IDs
+#idp.session.idSize = 32
+# Bind sessions to IP addresses
+#idp.session.consistentAddress = true
+# Inactivity timeout
+#idp.session.timeout = PT60M
+# Extra time to store sessions for logout
+#idp.session.slop = PT0S
+# Tolerate storage-related errors
+#idp.session.maskStorageFailure = false
+# Track information about SPs logged into
+idp.session.trackSPSessions = true
+# Support lookup by SP for SAML logout
+idp.session.secondaryServiceIndex = true
+# Length of time to track SP sessions
+#idp.session.defaultSPlifetime = PT2H
+
+# Set to "shibboleth.StorageService" or custom bean for alternate storage of consent
+#idp.consent.StorageService = shibboleth.ClientPersistentStorageService
+idp.consent.StorageService = shibboleth.StorageService
+
+# Set to "shibboleth.consent.AttributeConsentStorageKey" to use an attribute
+# to key user consent storage records (and set the attribute name)
+#idp.consent.attribute-release.userStorageKey = shibboleth.consent.PrincipalConsentStorageKey
+#idp.consent.attribute-release.userStorageKeyAttribute = uid
+#idp.consent.terms-of-use.userStorageKey = shibboleth.consent.PrincipalConsentStorageKey
+#idp.consent.terms-of-use.userStorageKeyAttribute = uid
+
+# Suffix of message property used as value of consent storage records when idp.consent.compareValues is true.
+# Defaults to text displayed to the user.
+#idp.consent.terms-of-use.consentValueMessageCodeSuffix = .text
+
+# Flags controlling how built-in attribute consent feature operates 
+#idp.consent.allowDoNotRemember = true
+#idp.consent.allowGlobal = true
+#idp.consent.allowPerAttribute = false
+
+# Whether attribute values and terms of use text are compared
+#idp.consent.compareValues = false
+# Maximum number of consent records for space-limited storage (e.g. cookies)
+#idp.consent.maxStoredRecords = 10
+# Maximum number of consent records for larger/server-side storage (0 = no limit)
+#idp.consent.expandedMaxStoredRecords = 0
+
+# Time in milliseconds to expire consent storage records.
+#idp.consent.storageRecordLifetime = P1Y
+
+# Whether to lookup metadata, etc. for every SP involved in a logout
+# for use by user interface logic; adds overhead so off by default.
+#idp.logout.elaboration = false
+
+# Whether to require logout requests/responses be signed/authenticated.
+#idp.logout.authenticated = true
+
+# Bean to determine whether user should be allowed to cancel logout
+#idp.logout.promptUser=shibboleth.Conditions.FALSE
+
+# Message freshness and replay cache tuning
+#idp.policy.messageLifetime = PT3M
+#idp.policy.clockSkew = PT3M
+
+# Set to custom bean for alternate storage of replay cache
+#idp.replayCache.StorageService = shibboleth.StorageService
+#idp.replayCache.strict = true
+
+# Toggles whether to allow outbound messages via SAML artifact
+#idp.artifact.enabled = true
+# Suppresses typical signing/encryption when artifact binding used
+#idp.artifact.secureChannel = true
+# May differ to direct SAML 2 artifact lookups to specific server nodes
+#idp.artifact.endpointIndex = 2
+# Set to custom bean for alternate storage of artifact map state
+#idp.artifact.StorageService = shibboleth.StorageService
+
+# Comma-delimited languages to use if not match can be found with the
+# browser-supported languages, defaults to an empty list.
+idp.ui.fallbackLanguages=en,fr,de
+
+# Storage service used by CAS protocol
+# Defaults to shibboleth.StorageService (in-memory)
+# MUST be server-side storage (e.g. in-memory, memcached, database)
+# NOTE that idp.session.StorageService requires server-side storage
+# when CAS protocol is enabled
+#idp.cas.StorageService=shibboleth.StorageService
+
+# CAS service registry implementation class
+#idp.cas.serviceRegistryClass=net.shibboleth.idp.cas.service.PatternServiceRegistry
+
+# F-TICKS auditing - set a salt to include hashed username
+#idp.fticks.federation=MyFederation
+#idp.fticks.algorithm=SHA-256
+#idp.fticks.salt=somethingsecret
+#idp.fticks.loghost=localhost
+#idp.fticks.logport=514
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/logback.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/logback.xml
new file mode 100644
index 0000000..2e38cbb
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/logback.xml
@@ -0,0 +1,197 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+
+    <!--
+    Variables for simplifying logging configuration.
+    http://logback.qos.ch/manual/configuration.html#variableSubstitution
+    -->
+
+    <!--
+    If you want to use custom properties in this config file,
+    we load the main property file for you.
+    -->
+    <variable file="src/test/resources/conf/local-log-config.properties" />
+
+    <!-- Location and retention. -->
+    
+    <variable name="idp.logfiles" value="${idp.logfiles:-${idp.home}/logs}" />
+    <variable name="idp.loghistory" value="${idp.loghistory:-180}" />
+    
+    <!-- Much higher performance if you operate on DEBUG. -->
+    <!-- <variable name="idp.process.appender" value="ASYNC_PROCESS" /> -->
+    
+    <!-- Logging level shortcuts. -->
+    <variable name="idp.loglevel.idp" value="${idp.loglevel.idp:-OFF}" />
+    <variable name="idp.loglevel.ldap" value="${idp.loglevel.ldap:-OFF}" />
+    <variable name="idp.loglevel.messages" value="${idp.loglevel.messages:-OFF}" />
+    <variable name="idp.loglevel.encryption" value="${idp.loglevel.encryption:-OFF}" />
+    <variable name="idp.loglevel.opensaml" value="${idp.loglevel.opensaml:-OFF}" />
+    <variable name="idp.loglevel.props" value="${idp.loglevel.props:-OFF}" />
+    <variable name="idp.loglevel.httpclient" value="${idp.loglevel.httpclient:-OFF}" />
+
+    <variable name="idp.loglevel.oidc" value="${idp.loglevel.oidc:-OFF}" />
+    <variable name="idp.loglevel.oidc-op" value="${idp.loglevel.oidc-op:-OFF}" />    
+    
+    <!-- Don't turn these up unless you want a *lot* of noise. -->
+    <variable name="idp.loglevel.spring" value="${idp.loglevel.spring:-OFF}" />
+    <variable name="idp.loglevel.container" value="${idp.loglevel.container:-OFF}" />
+    <variable name="idp.loglevel.xmlsec" value="${idp.loglevel.xmlsec:-OFF}" />
+
+    <!-- =========================================================== -->
+    <!-- ============== Logging Categories and Levels ============== -->
+    <!-- =========================================================== -->
+
+    <!-- Logs IdP, but not OpenSAML, messages -->
+    <logger name="net.shibboleth" level="${idp.loglevel.idp}"/>
+    <logger name="net.shibboleth.oidc" level="${idp.loglevel.oidc}"/>
+    <logger name="net.shibboleth.idp.plugin.oidc.op" level="${idp.loglevel.oidc-op}"/>
+
+    <!-- Logs OpenSAML, but not IdP, messages -->
+    <logger name="org.opensaml.saml" level="${idp.loglevel.opensaml}"/>
+    
+    <!-- Logs LDAP related messages -->
+    <logger name="org.ldaptive" level="${idp.loglevel.ldap}"/>
+
+    <!-- Logs embedded HTTP client messages -->
+    <logger name="org.apache.http" level="${idp.loglevel.httpclient}"/>
+    
+    <!-- Logs inbound and outbound protocols messages at DEBUG level -->
+    <logger name="PROTOCOL_MESSAGE" level="${idp.loglevel.messages}" />
+
+    <!-- Logs unencrypted SAML at DEBUG level -->
+    <logger name="org.opensaml.saml.saml2.encryption.Encrypter" level="${idp.loglevel.encryption}" />
+    <logger name="org.opensaml.saml.saml2.encryption.Decrypter" level="${idp.loglevel.encryption}" />
+
+    <!-- Logs system properties during startup at DEBUG level -->
+    <logger name="net.shibboleth.idp.log.LogbackLoggingService" level="${idp.loglevel.props}" />
+
+    <!-- Especially chatty. -->
+    <logger name="org.apache.xml.security" level="${idp.loglevel.xmlsec}" />
+    <logger name="org.springframework" level="${idp.loglevel.spring}"/>
+    <logger name="org.apache.catalina" level="${idp.loglevel.container}"/>
+    <logger name="org.eclipse.jetty" level="${idp.loglevel.container}"/>
+
+
+    <!-- =========================================================== -->
+    <!-- ============== Low Level Details or Changes =============== -->
+    <!-- =========================================================== -->
+    
+    <!-- Process log. -->
+    <appender name="IDP_PROCESS" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <File>${idp.logfiles}/idp-process.log</File>
+        
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${idp.logfiles}/idp-process-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>${idp.loghistory}</maxHistory>
+        </rollingPolicy>
+
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <charset>UTF-8</charset>
+            <Pattern>%date{ISO8601} - %mdc{idp.remote_addr} - %level [%logger:%line] - %msg%n%ex{short}</Pattern>
+        </encoder>
+
+        <!-- Ignore Velocity status page error. -->
+        <filter class="ch.qos.logback.core.filter.EvaluatorFilter">
+            <evaluator>
+                <matcher>
+                    <Name>VelocityStatusMatcher</Name>
+                    <regex>ResourceManager\s*: unable to find resource 'status\.vm' in any resource loader\.</regex>
+                </matcher>
+                <expression>VelocityStatusMatcher.matches(formattedMessage)</expression>
+            </evaluator>
+            <OnMatch>DENY</OnMatch>
+        </filter>
+    </appender>
+
+    <appender name="ASYNC_PROCESS" class="ch.qos.logback.classic.AsyncAppender">
+        <appender-ref ref="IDP_PROCESS" />
+        <discardingThreshold>0</discardingThreshold>
+    </appender>
+
+    <appender name="IDP_WARN" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- Suppress anything below WARN. -->
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>WARN</level>
+        </filter>
+        
+        <File>${idp.logfiles}/idp-warn.log</File>
+        
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${idp.logfiles}/idp-warn-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>${idp.loghistory}</maxHistory>
+        </rollingPolicy>
+        
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <charset>UTF-8</charset>
+            <Pattern>%date{ISO8601} - %mdc{idp.remote_addr} - %level [%logger:%line] - %msg%n%ex{full}</Pattern>
+        </encoder>
+        
+        <!-- Ignore Velocity status page error. -->
+        <filter class="ch.qos.logback.core.filter.EvaluatorFilter">
+            <evaluator>
+                <matcher>
+                    <Name>VelocityStatusMatcher</Name>
+                    <regex>ResourceManager\s*: unable to find resource 'status\.vm' in any resource loader\.</regex>
+                </matcher>
+                <expression>VelocityStatusMatcher.matches(formattedMessage)</expression>
+            </evaluator>
+            <OnMatch>DENY</OnMatch>
+        </filter>
+    </appender>
+    
+    <!-- Audit log. -->
+    <appender name="IDP_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <File>${idp.logfiles}/idp-audit.log</File>
+
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${idp.logfiles}/idp-audit-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>${idp.loghistory}</maxHistory>
+        </rollingPolicy>
+
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <charset>UTF-8</charset>
+            <Pattern>%msg%n</Pattern>
+        </encoder>
+    </appender>
+    
+    <!-- Consent audit log. -->
+    <appender name="IDP_CONSENT_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <File>${idp.logfiles}/idp-consent-audit.log</File>
+
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${idp.logfiles}/idp-consent-audit-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
+            <maxHistory>${idp.loghistory}</maxHistory>
+        </rollingPolicy>
+
+        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
+            <charset>UTF-8</charset>
+            <Pattern>%msg%n</Pattern>
+        </encoder>
+    </appender>
+
+    <!-- F-TICKS syslog destination. -->
+    <appender name="IDP_FTICKS" class="ch.qos.logback.classic.net.SyslogAppender">
+        <syslogHost>${idp.fticks.loghost:-localhost}</syslogHost>
+        <port>${idp.fticks.logport:-514}</port>
+        <facility>AUTH</facility>
+        <suffixPattern>[%thread] %logger %msg</suffixPattern>
+    </appender>
+
+    <logger name="Shibboleth-Audit" level="${idp.loglevel.audit:-OFF}">
+        <appender-ref ref="${idp.audit.appender:-IDP_AUDIT}"/>
+    </logger>
+
+    <logger name="Shibboleth-FTICKS" level="${idp.loglevel.fticks:-OFF}" additivity="false">
+        <appender-ref ref="${idp.fticks.appender:-IDP_FTICKS}"/>
+    </logger>
+
+    <logger name="Shibboleth-Consent-Audit" level="${idp.loglevel.consent-audit:-OFF}">
+        <appender-ref ref="${idp.consent.appender:-IDP_CONSENT_AUDIT}"/>
+    </logger>
+    
+    <root level="${idp.loglevel.root:-OFF}">
+        <appender-ref ref="${idp.process.appender:-IDP_PROCESS}"/>
+        <appender-ref ref="${idp.warn.appender:-IDP_WARN}" />
+    </root>
+
+</configuration>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/metadata-providers.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/metadata-providers.xml
new file mode 100644
index 0000000..3b78e27
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/metadata-providers.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This file is an EXAMPLE metadata configuration file. -->
+<MetadataProvider id="ShibbolethMetadata" xsi:type="ChainingMetadataProvider"
+        xmlns="urn:mace:shibboleth:2.0:metadata" xmlns:resource="urn:mace:shibboleth:2.0:resource"
+        xmlns:security="urn:mace:shibboleth:2.0:security"
+        xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
+        xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
+        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
+        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+        xsi:schemaLocation="urn:mace:shibboleth:2.0:metadata http://shibboleth.net/schema/idp/shibboleth-metadata.xsd
+                        urn:mace:shibboleth:2.0:resource http://shibboleth.net/schema/idp/shibboleth-resource.xsd 
+                        urn:mace:shibboleth:2.0:security http://shibboleth.net/schema/idp/shibboleth-security.xsd
+                        urn:oasis:names:tc:SAML:2.0:assertion http://docs.oasis-open.org/security/saml/v2.0/saml-schema-assertion-2.0.xsd
+                        urn:oasis:names:tc:SAML:2.0:metadata http://docs.oasis-open.org/security/saml/v2.0/saml-schema-metadata-2.0.xsd">
+                        
+        <!-- ========================================== -->
+        <!-- Metadata Configuration -->
+        <!-- ========================================== -->
+
+    <MetadataProvider id="SP1MD" xsi:type="ResourceBackedMetadataProvider"
+        maxRefreshDelay="PT5M" indexesRef="testbed.MetadataIndexes"
+        resourceRef="exampleMetadata-saml-oidc-clientsecret" />
+
+    <MetadataProvider id="SP2MD" xsi:type="ResourceBackedMetadataProvider"
+        maxRefreshDelay="PT5M" indexesRef="testbed.MetadataIndexes"
+        resourceRef="exampleMetadata-saml-oauth2-resource" />
+
+</MetadataProvider>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-credentials.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-credentials.xml
new file mode 100644
index 0000000..8d920ed
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-credentials.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <!-- This file contains default oidfed signing credentials. This file should be imported to credentials.xml -->
+
+    <bean id="shibboleth.oidfed.DefaultRSSigningCredential"
+        class="net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean"
+        p:resource="/credentials/fed-signing-rs.jwk" />
+
+    <bean id="shibboleth.oidfed.DefaultES256SigningCredential"
+        class="net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean"
+        p:resource="/credentials/fed-signing-es256.jwk" />
+
+    <bean id="shibboleth.oidfed.DefaultES384SigningCredential"
+        class="net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean"
+        p:resource="/credentials/fed-signing-es384.jwk" />
+
+    <bean id="shibboleth.oidfed.DefaultES521SigningCredential"
+        class="net.shibboleth.oidc.security.credential.BasicJWKCredentialFactoryBean"
+        p:resource="/credentials/fed-signing-es521.jwk" />
+
+    <util:list id="shibboleth.oidfed.SigningCredentials">
+        <ref bean="shibboleth.oidfed.DefaultRSSigningCredential" />
+        <ref bean="shibboleth.oidfed.DefaultES256SigningCredential" />
+        <ref bean="shibboleth.oidfed.DefaultES384SigningCredential" />
+        <ref bean="shibboleth.oidfed.DefaultES521SigningCredential" />
+    </util:list>
+
+</beans>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-claims.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-claims.xml
new file mode 100644
index 0000000..9a2accb
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-claims.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <bean id="ExampleTrustedTrustMarkIssuer"
+        class="net.shibboleth.oidfed.profile.TrustedRemoteEntity"
+        c:entity="https://dyn-trust-mark-issuer.federation.local"/>
+
+    <util:map id="shibboleth.oidfed.EntityConfigurationClaimsLookupStrategies"
+        value-type="java.util.function.Function">
+        <entry key="trust_anchor_hints" value-ref="#{'%{idp.oidfed.entity-configuration.trustAnchoHintsLookup:DefaultTrustAnchorHintsLookupStrategy}'.trim()}"/>
+        <entry key="trust_marks">
+            <bean class="net.shibboleth.oidfed.profile.navigate.DefaultEntityConfigurationTrustMarksLookupStrategy">
+                <property name="trustMarkLookupStrategies">
+                    <util:list value-type="java.util.function.Function">
+                        <bean parent="shibboleth.oidfed.RemoteTrustMark"
+                            p:trustMarkType="https://dyn-trust-mark-issuer.federation.local/example"
+                            p:trustedEntity-ref="ExampleTrustedTrustMarkIssuer" />
+                        <bean parent="shibboleth.Functions.Constant">
+                            <constructor-arg name="target">
+                                <util:map key-type="java.lang.String" value-type="java.lang.String">
+                                    <entry
+                                        key="trust_mark_type"
+                                        value="https://example.org/a-trust-mark" />
+                                    <entry
+                                        key="trust_mark"
+                                        value="eyJraWQiOiJtb2NrVHJ1c3RNYXJrSXNzdWVyS2V5IiwidHlwIjoidHJ1c3QtbWFyaytqd3QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3RydXN0LW1hcmstaXNzdWVyLmZlZGVyYXRpb24ubG9jYWwiLCJzdWIiOiJodHRwczovL29wLmV4YW1wbGUub3JnIiwidHJ1c3RfbWFya190eXBlIjoiaHR0cHM6Ly9leGFtcGxlLm9yZy9hLXRydXN0LW1hcmsiLCJleHAiOjQ5MTgzNjczMzYsImlhdCI6MTc2NDc2NzMzNn0.smmtxeU_vCh2XFHLCxGHtwr_ZQ9A0-T7V9Poq5tNqwuU7_QlMAUJG1CJcprqQ9hH2oNSSQPIfUk7fOB1VUEY66U_bGBQ-KNQiIj-j25IQs7JalOCT1qjzcsMkq6i [...]
+                                </util:map>
+                            </constructor-arg>
+                        </bean>
+                    </util:list>
+                </property>
+            </bean>
+        </entry>
+    </util:map>
+
+</beans>
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-metadata.json b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-metadata.json
new file mode 100644
index 0000000..44b57aa
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-entity-configuration-metadata.json
@@ -0,0 +1,11 @@
+{
+    "federation_entity": {
+        "organization_name" : "Example organization",
+        "organization_uri" : "https://org.example.org",
+        "contacts" : [ "contact at example.org" ]
+    },
+    "custom_entity_type": {
+        "key0" : "static_value",
+        "key1" : "static_value"
+    }
+}
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json
new file mode 100644
index 0000000..ff5a6b9
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json
@@ -0,0 +1,24 @@
+{
+    "https://trust-anchor.federation.local": {
+        "keys": [
+            {
+                "alg": "RS256",
+                "kty":"RSA",
+                "e":"AQAB",
+                "kid":"locallyTrustedAnchorKey",
+                "n":"n0-NFV06ZDKLo1v8KrSJsQ8bbLEffVJw1F5jGXqrKh_4PpBt9FmyWY3gIA9aK1p1WneMaWRNlM1EObierCr0EdXCQbgpKorrPqxiwyl6cOMIH4fN_9uWGqD2HlyGcjcESrNjZz75tNr_9oegh6fWSMgrxyySpU38ALWUX1ZuNS8A4tj8XdJSbSHqftf7qOdgzuy0yaD5h7NwoBCRPOIY88vOLHkcQ4nYdkk8GLSIf5GgGb7JFiPuFHN7pK---LNnFBifag2wbEZ9nnAcAol4jc2gF7zq2mqhMSlbIVmTRj4Y9wxh3DPbmC8xZ-8nbhPmgi4vlij9JWJGEvfLuXaMGQ"
+            }
+        ]
+    },
+    "https://local-trusted-intermediate-authority.federation.local": {
+        "keys": [
+            {
+                "alg": "RS256",
+                "kty":"RSA",
+                "e":"AQAB",
+                "kid":"locallyTrustedIntermediateKey",
+                "n":"kYK2ScZiF-zm9VnOS0MXMrszjOtEj7_YmyuJzyG3VHZPH5EPR_79MwzXgRUxFQXTHFw93NQbiIL3m6TX6RDCtphPdO7iVTF_aNRqCy3Hdn7Ws8NQbAHyj9WihoN6b-euFc_T-C7hkPYJcoIG9A9X0rcXyE2hlS4n386eWSR-gTHjvoZQ_M63Eoo4FZVx_8213Qmx0Pwypi-iZ1mW745TI1FvuYzZfZk0xGdfxsgJzQLnXPm_hcQJKMcOFPxdqw0xLqKdByp2LKjrqlOIoZKgTOeWvFr_TV21vKC5ijp1Nh4POwkI-urAl6fmELx_Mn_z7ZvwUhefzznnRgL6xt77gw"
+            }
+        ]
+    }
+}
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trustchain-resolver.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trustchain-resolver.xml
new file mode 100644
index 0000000..7840bec
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trustchain-resolver.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+
+    default-init-method="initialize" default-destroy-method="destroy">
+
+    <bean id="shibboleth.oidfed.DefaultTrustedRemoteResolverEntitiesLookupStrategy" parent="shibboleth.Functions.Constant">
+        <constructor-arg name="target">
+            <util:list value-type="net.shibboleth.oidfed.profile.TrustedRemoteResolverEntity">
+                <bean class="net.shibboleth.oidfed.profile.TrustedRemoteResolverEntity"
+                    c:entity="https://trust-anchor.federation.local"
+                    c:anchors="https://notworking.local,https://neither.another.local" />
+                <bean class="net.shibboleth.oidfed.profile.TrustedRemoteResolverEntity"
+                    c:entity="https://trust-anchor.federation.local"
+                    c:anchors="https://trust-anchor.federation.local" />
+            </util:list>
+        </constructor-arg>
+    </bean>
+
+</beans>
\ No newline at end of file
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed.properties b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed.properties
new file mode 100644
index 0000000..9eb3c72
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed.properties
@@ -0,0 +1,13 @@
+idp.oidfed.entityID = https://op.example.org
+idp.oidfed.entity.authorityHints = https://anchor1.example.org, https://anchor2.example.org
+
+idp.oidfed.cache.entityConfiguration.invalidContainerLifetime = PT0S
+idp.oidfed.cache.entityConfiguration.minRefreshDelay = PT0S
+idp.oidfed.cache.signedKeyset.invalidContainerLifetime = PT0S
+idp.oidfed.cache.signedKeyset.minRefreshDelay = PT0S
+
+idp.oidfed.cache.default.critClaims = default_crit
+idp.oidfed.cache.entityConfiguration.customFilterStrategies = CustomEntityConfigurationFilters
+idp.oidfed.cache.subordinateStatement.customFilterStrategies = CustomSubordinateStatementFilters
+idp.oidfed.cache.subordinateStatement.critClaims = subordinate_crit
+idp.oidfed.cache.trustChain.customFilterStrategies = CustomTrustChainFilters
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml
new file mode 100644
index 0000000..9ab8124
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/relying-party.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans xmlns="http://www.springframework.org/schema/beans"
+       xmlns:context="http://www.springframework.org/schema/context"
+       xmlns:util="http://www.springframework.org/schema/util"
+       xmlns:p="http://www.springframework.org/schema/p"
+       xmlns:c="http://www.springframework.org/schema/c"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+                           
+       default-init-method="initialize"
+       default-destroy-method="destroy">
+
+    <bean id="shibboleth.UnverifiedRelyingParty" parent="RelyingParty">
+        <property name="profileConfigurations">
+            <list>
+                <bean parent="OIDFED.Configuration" p:cachedSuccessResponseLifetime="PT2S" />
+            </list>
+        </property>
+    </bean>
+
+    <bean id="shibboleth.DefaultRelyingParty" parent="RelyingParty.MDDriven">
+        <property name="profileConfigurations">
+            <list>
+            </list>
+        </property>
+    </bean>
+
+    <util:list id="shibboleth.RelyingPartyOverrides">
+    </util:list>
+
+</beans>
diff --git a/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/services.xml b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/services.xml
new file mode 100644
index 0000000..c2cf11c
--- /dev/null
+++ b/oidfed-common-conf-impl/src/test/resources/net/shibboleth/idp/module/conf/services.xml
@@ -0,0 +1,92 @@
+<beans xmlns="http://www.springframework.org/schema/beans"
+    xmlns:context="http://www.springframework.org/schema/context"
+    xmlns:util="http://www.springframework.org/schema/util" xmlns:p="http://www.springframework.org/schema/p"
+    xmlns:c="http://www.springframework.org/schema/c" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+                           
+    default-init-method="initialize"
+    default-destroy-method="destroy">
+                               
+    <!-- Advanced configuration of services from HTTP.
+    
+      To use an HTTP resource you first need to configure the Apache HttpClient which will be used
+      to communicate with the web server.  Any HttpClient can be used, but two Factory Beans allow simple
+      configuration of in-memory or file-based caching clients.
+      
+      Examples are:
+      
+        A resource which will be supplied from an in-memory cache for as long as the file on the webserver does not change.  
+        If the webserver becomes unavailable the resource will be unavailable.
+         
+        <bean id="inMemoryResource" class="net.shibboleth.ext.spring.resource.HTTPResource"
+              c:client-ref="shibboleth.MemoryCachingHttpClient" 
+              c:url="http://example.org/path/to/file.xml" />
+              
+        Two resources which will be supplied from an on disk cache (suitable for multiple or large files) for as long 
+        as the file on the webserver does not change.  If the webserver becomes unavailable the last used contents
+        of the file will be returned (even if that was in a previous IdP lifetime).
+        
+        <bean id="fileResource" class="net.shibboleth.ext.spring.resource.FileBackedHTTPResource"
+              c:client-ref="shibboleth.FileCachingHttpClient" 
+              c:url="http://example.org/path/to/file.xml"
+              c:backingFile="/var/shibboleth/caches/resourcecache/file.xml"/>
+       
+        <bean id="otherFileResource" class="net.shibboleth.ext.spring.resource.FileBackedHTTPResource"
+              c:client-ref="shibboleth.FileCachingHttpClient" 
+              c:url="http://another.server.example.org/path/to/different/file.xml"
+              c:backingFile="/var/shibboleth/caches/resourcecache/differentFile.xml"/>
+              
+        In all cases you should review the "idp.httpclient.*" properties defined in services.properties
+    -->
+    
+    <!--
+    Otherwise by default we look at resources whose names are derived from %{idp.home}. Services not configured
+    using native Spring syntax also need to load the property-placeholder file in order to pull settings from
+    property sources.
+    -->
+
+    <!-- This set of resources supports a native Spring relying-party.xml file. -->
+    <util:list id="shibboleth.RelyingPartyResolverResources">
+        <value>%{idp.home}/conf/relying-party.xml</value>
+        <value>%{idp.home}/conf/credentials.xml</value>
+    </util:list>
+
+    <util:list id="shibboleth.MetadataResolverResources">
+        <value>%{idp.home}/conf/metadata-providers.xml</value>
+    </util:list>
+
+    <!-- This set of resources uses only AttributeEncoders for compatibility. -->
+    <util:list id ="shibboleth.AttributeRegistryResources">
+        <value>%{idp.home}/conf/attribute-registry.xml</value>
+        <value>%{idp.home}/conf/attribute-resolver.xml</value>
+<!--         <value>%{idp.home}/conf/attributes/default-rules.xml</value> -->
+    </util:list>
+
+    <util:list id ="shibboleth.AttributeResolverResources">
+        <value>%{idp.home}/conf/attribute-resolver.xml</value>
+    </util:list>
+
+    <util:list id ="shibboleth.AttributeFilterResources">
+        <value>%{idp.home}/conf/attribute-filter.xml</value>
+    </util:list>
+
+    <util:list id ="shibboleth.NameIdentifierGenerationResources">
+        <value>%{idp.home}/conf/saml-nameid.xml</value>
+    </util:list>
+    
+    <util:list id="shibboleth.AccessControlResources">
+        <value>%{idp.home}/conf/access-control.xml</value>
+    </util:list>
+
+    <!--
+    This collection of resources differs slightly in that it should not include the file extension.
+    Message sources are internationalized, and Spring will search for a compatible language extension
+    and fall back to one with only a .properties extension.
+    -->
+    <util:list id="shibboleth.MessageSourceResources">
+        <value>%{idp.home}/messages/messages</value>
+    </util:list>
+    
+</beans>
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/AbstractBuildEntityStatementAction.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/AbstractBuildEntityStatementAction.java
new file mode 100644
index 0000000..d9f98fb
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/AbstractBuildEntityStatementAction.java
@@ -0,0 +1,301 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.Date;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.messaging.encoder.AbstractMessageEncoder;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidfed.profile.config.navigate.EntityStatementClaimsSetManipulationStrategyLookupFunction;
+import net.shibboleth.profile.context.navigate.IssuerLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.FunctionSupport;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.security.IdentifierGenerationStrategy;
+import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
+
+/**
+ * Abstract action used by actions that build entity statements.
+ */
+public abstract class AbstractBuildEntityStatementAction extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(AbstractBuildEntityStatementAction.class);
+
+    /** Used to log protocol messages. */
+    @Nonnull protected Logger protocolMessageLog =
+            LoggerFactory.getLogger(AbstractMessageEncoder.BASE_PROTOCOL_MESSAGE_LOGGER_CATEGORY + ".OIDFED");
+
+    /** Strategy used to obtain the issuer value. */
+    @Nonnull private Function<ProfileRequestContext,String> issuerLookupStrategy;
+
+    /** Strategy used to obtain the subject value. */
+    @Nonnull private Function<ProfileRequestContext,String> subjectLookupStrategy;
+
+    /** Strategy used to locate the {@link IdentifierGenerationStrategy} to use. */
+    @Nonnull private Function<ProfileRequestContext,IdentifierGenerationStrategy> idGeneratorLookupStrategy;
+
+    /** Strategy used to locate the subcontext to hold the statement. */
+    @Nonnull private Function<ProfileRequestContext,EntityStatementContext> entityStatementContextLookupStrategy;
+
+    /** Lookup function to supply strategy bi-function for manipulating entity statement claims set. */ 
+    @Nonnull
+    private Function<ProfileRequestContext,BiFunction<ProfileRequestContext,Map<String,Object>,Map<String,Object>>>
+        entityStatementClaimsSetManipulationStrategyLookupStrategy;
+
+    /** The strategy used for manipulating the entity statement claims set. */
+    @Nullable private BiFunction<ProfileRequestContext,Map<String,Object>,Map<String,Object>> manipulationStrategy;
+
+    /** Object mapper used for pretty-printing JWT contents. */
+    @NonnullAfterInit private ObjectMapper objectMapper;
+
+    /** The generator to use. */
+    @NonnullBeforeExec protected IdentifierGenerationStrategy idGenerator;
+    
+    /** Entity statement context. */
+    @NonnullBeforeExec protected EntityStatementContext entityStatementCtx;
+
+    /** Constructor. */
+    public AbstractBuildEntityStatementAction() {
+        issuerLookupStrategy = new IssuerLookupFunction();
+        subjectLookupStrategy = new IssuerLookupFunction();
+        
+        idGeneratorLookupStrategy = FunctionSupport.constant(new SecureRandomIdentifierGenerationStrategy());
+
+        final Function<ProfileRequestContext,EntityStatementContext> escls =
+                new ChildContextLookup<>(EntityStatementContext.class, true).compose(
+                        new OutboundMessageContextLookup());
+        assert escls != null;
+        entityStatementContextLookupStrategy = escls; 
+
+        entityStatementClaimsSetManipulationStrategyLookupStrategy =
+                new EntityStatementClaimsSetManipulationStrategyLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the {@link IdentifierGenerationStrategy} to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setIdentifierGeneratorLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,IdentifierGenerationStrategy> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        idGeneratorLookupStrategy =
+                Constraint.isNotNull(strategy, "Identifier generation strategy cannot be null");
+    }
+    
+    /**
+     * Set the strategy used to locate the issuer value to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setIssuerLookupStrategy(@Nonnull final Function<ProfileRequestContext,String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        
+        issuerLookupStrategy = Constraint.isNotNull(strategy, "Issuer lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the subject value to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setSubjectLookupStrategy(@Nonnull final Function<ProfileRequestContext,String> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        
+        subjectLookupStrategy = Constraint.isNotNull(strategy, "Subject lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to lookup the {@link EntityStatementContext} to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setEntityStatementContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,EntityStatementContext> strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+        
+        entityStatementContextLookupStrategy =
+                Constraint.isNotNull(strategy, "EntityStatementContext lookup strategy cannot be null");
+    }
+    
+    /**
+     * Set the lookup function to supply strategy bi-function for manipulating entity statement claims set.
+     * 
+     * @param strategy What to set
+     */
+    public void setEntityStatementClaimsSetManipulationStrategyLookupStrategy(@Nonnull final
+            Function<ProfileRequestContext,BiFunction<ProfileRequestContext,Map<String,Object>,Map<String,Object>>>
+            strategy) {
+        ifInitializedThrowUnmodifiabledComponentException();
+
+        entityStatementClaimsSetManipulationStrategyLookupStrategy =
+                Constraint.isNotNull(strategy, "Manipulation strategy lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the object mapper used for pretty-printing JWT contents.
+     * 
+     * @param mapper What to set.
+     */
+    public void setObjectMapper(@Nonnull final ObjectMapper mapper) {
+        checkSetterPreconditions();
+        objectMapper = Constraint.isNotNull(mapper, "Object mapper cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (objectMapper == null) {
+            throw new ComponentInitializationException("Object mapper cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        idGenerator = idGeneratorLookupStrategy.apply(profileRequestContext);
+        if (idGenerator == null) {
+            log.error("{} No identifier generation strategy", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        entityStatementCtx = entityStatementContextLookupStrategy.apply(profileRequestContext);
+        if (entityStatementCtx == null) {
+            log.error("{} Unable to fetch EntityStatementContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        manipulationStrategy =
+                entityStatementClaimsSetManipulationStrategyLookupStrategy.apply(profileRequestContext);
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        final String issuer = issuerLookupStrategy.apply(profileRequestContext);
+        final String subject = subjectLookupStrategy.apply(profileRequestContext);
+
+        final Instant now = Instant.now();
+
+        final JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+                .issuer(issuer)
+                .subject(subject)
+                .issueTime(Date.from(now));
+        assert builder != null;
+        if (!populateClaimsSetBuilder(builder, profileRequestContext)) {
+            return;
+        }
+        final JWTClaimsSet claimsSet = builder.build();
+
+        assert claimsSet != null;
+        if (manipulationStrategy != null) {
+            log.debug("{} Manipulation strategy has been set, applying it to the claims set {}", getLogPrefix(),
+                    claimsSet.toJSONObject());
+            assert manipulationStrategy != null;
+            final Map<String, Object> result = manipulationStrategy.apply(profileRequestContext,
+                    claimsSet.toJSONObject());
+            if (result == null) {
+                log.debug("{} Manipulation strategy returned null, leaving statement claims set untouched.",
+                        getLogPrefix());
+            } else {
+                log.debug("{} Applying the manipulated claims into the entity statement claims set", getLogPrefix());
+                try {
+                    final JWTClaimsSet parsedSet = JWTClaimsSet.parse(result);
+                    assert parsedSet != null;
+                    logAndConstructEntityStatement(parsedSet);
+                    return;
+                } catch (final ParseException e) {
+                    log.error("{} The resulted claims set could not be transformed into ", getLogPrefix(), e);
+                    ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+                    return;
+                }
+            }
+        } else {
+            log.debug("{} No manipulation strategy configured", getLogPrefix());
+        }
+        logAndConstructEntityStatement(claimsSet);
+    }
+
+    /**
+     * Populates the claims set builder with claims specific to the action extending this abstract action. If any
+     * problem occures during population, the profile request context should be populated with an appropriate
+     * event.
+     * 
+     * @param builder the claims set builder
+     * @param profileRequestContext profile request context
+     * @return true if population was successful, false otherwise
+     */
+    protected abstract boolean populateClaimsSetBuilder(@Nonnull final JWTClaimsSet.Builder builder,
+            @Nonnull final ProfileRequestContext profileRequestContext);
+
+    /**
+     * Logs the entity statement contents via protocol message logger and constructs a plain (i.e. non-signed) JWT out
+     * of it and includes it to the {@link EntityStatementContext#setJWT(JWT)}.
+     * 
+     * @param claimsSet the claims set
+     */
+    protected void logAndConstructEntityStatement(@Nonnull final JWTClaimsSet claimsSet) {
+        log.trace("{} Building JWT from the claims set {}", getLogPrefix(), claimsSet);
+        assert objectMapper != null;
+        try {
+            final Object jsonObject = objectMapper.readValue(claimsSet.toString(), Object.class);
+            final String contents = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonObject);
+            protocolMessageLog.trace("Entity statement payload contents:\n{}", contents);
+        } catch (final JsonProcessingException e) {
+            log.error("{} Could not construct protocol log message", getLogPrefix(), e);
+        }
+        assert entityStatementCtx != null;
+        final JWT jwt = new PlainJWT(claimsSet);
+        entityStatementCtx.setJWT(jwt);
+    }
+}
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/BuildEntityConfiguration.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/BuildEntityConfiguration.java
new file mode 100644
index 0000000..aace6ed
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/BuildEntityConfiguration.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.profile.config.navigate.JWTSignatureSigningConfigurationLookupFunction;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.jose.SignatureSigningConfiguration;
+import net.shibboleth.oidfed.metadata.payload.claim.Metadata;
+import net.shibboleth.oidfed.profile.config.navigate.AuthorityHintsLookupFunction;
+import net.shibboleth.oidfed.profile.config.navigate.EntityStatementLifetimeLookupFunction;
+import net.shibboleth.oidfed.profile.config.navigate.OptionalClaimsLookupStrategiesLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+import org.opensaml.profile.action.ActionSupport;
+
+/**
+ * Action that creates an Entity Statement, and stores it to an {@link EntityStatementContext}.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#IO_ERROR}
+ * @event {@link IdPEventIds#INVALID_PROFILE_CONFIG}
+ */
+public class BuildEntityConfiguration extends AbstractBuildEntityStatementAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(BuildEntityConfiguration.class);
+
+    /** Strategy used to locate the {@link SignatureSigningConfiguration}s to fetch JWK set from. */
+    @Nonnull private
+    Function<ProfileRequestContext,List<SignatureSigningConfiguration>> signingConfigurationsLookupStrategy;
+
+    /** Strategy used to obtain the entity statement lifetime. */
+    @Nonnull private Function<ProfileRequestContext,Duration> entityConfigurationLifetimeLookupStrategy;
+
+    /** Strategy used to locate authority hints. */
+    @Nonnull private Function<ProfileRequestContext,List<String>> authorityHintsLookupStrategy;
+
+    /** Strategy used to locate strategies for optional claims. */
+    @Nonnull private Function<ProfileRequestContext,Map<String, Function<ProfileRequestContext,Object>>>
+        optionalClaimsLookupStrategiesLookupStrategy;
+
+    /** Metadata to publish. */
+    @NonnullBeforeExec private Metadata metadata;
+
+    /** Constructor. */
+    public BuildEntityConfiguration() {
+        signingConfigurationsLookupStrategy = new JWTSignatureSigningConfigurationLookupFunction();
+        entityConfigurationLifetimeLookupStrategy = new EntityStatementLifetimeLookupFunction();
+        authorityHintsLookupStrategy = new AuthorityHintsLookupFunction();
+        optionalClaimsLookupStrategiesLookupStrategy = new OptionalClaimsLookupStrategiesLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the {@link SignatureSigningConfiguration}s to fetch JWK set from. 
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setSigningConfigurationsLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,List<SignatureSigningConfiguration>> strategy) {
+        checkSetterPreconditions();
+
+        signingConfigurationsLookupStrategy =
+                Constraint.isNotNull(strategy, "Signing configuration lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to obtain the entity configuration lifetime.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setEntityConfigurationLifetimeLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,Duration> strategy) {
+        checkSetterPreconditions();
+        
+        entityConfigurationLifetimeLookupStrategy =
+                Constraint.isNotNull(strategy, "Entity configuration lifetime lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate authority hints.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setAuthorityHintsLookupStrategy(@Nonnull final Function<ProfileRequestContext,List<String>> strategy) {
+        checkSetterPreconditions();
+
+        authorityHintsLookupStrategy = Constraint.isNotNull(strategy, "Authority hints lookup strategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate strategies for optional claims.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setOptionalClaimsLookupStrategiesLookupStrategy(@Nonnull final
+            Function<ProfileRequestContext, Map<String,Function<ProfileRequestContext,Object>>> strategy) {
+        checkSetterPreconditions();
+
+        optionalClaimsLookupStrategiesLookupStrategy =
+                Constraint.isNotNull(strategy, "Optional claims lookup strategies lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        metadata = entityStatementCtx.getMetadata();
+        if (metadata == null) {
+            log.error("{} Could not resolve provider metadata", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean populateClaimsSetBuilder(@Nonnull final JWTClaimsSet.Builder builder,
+            @Nonnull final ProfileRequestContext profileRequestContext) {
+        final List<SignatureSigningConfiguration> signingConfigurations =
+                signingConfigurationsLookupStrategy.apply(profileRequestContext);
+        if (signingConfigurations == null || signingConfigurations.isEmpty()) {
+            log.error("{} Could not fetch any signature signing configurations", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+
+        final List<JWK> jwks = new ArrayList<>();
+        for (final SignatureSigningConfiguration signingConfiguration : signingConfigurations) {
+            for (final Credential credential : signingConfiguration.getSigningCredentials()) {
+                final JWK jwk = CredentialConversionUtil.credentialToKey(credential);
+                if (jwk != null) {
+                    jwks.add(jwk);
+                    log.debug("{} Included {} to the keyset", getLogPrefix(), jwk.toJSONString());
+                }
+            }
+        }
+
+        final Duration lifetime = entityConfigurationLifetimeLookupStrategy.apply(profileRequestContext);
+        if (lifetime == null) {
+            log.error("{} No lifetime supplied for entity statement", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+            return false;
+        }
+        final Instant now = Instant.now();
+        final Instant dateExp = now.plus(lifetime);
+        assert dateExp != null;
+
+        builder.expirationTime(Date.from(dateExp));
+        builder.claim("jwks", new JWKSet(jwks).toJSONObject(true));
+        builder.claim("metadata", CollectionSupport.copyToMap(metadata.getAllClaims()));
+        final List<String> authorityHints = authorityHintsLookupStrategy.apply(profileRequestContext);
+        if (authorityHints != null && !authorityHints.isEmpty()) {
+            builder.claim("authority_hints", authorityHints);
+        }
+        final Map<String, Function<ProfileRequestContext, Object>> optionalClaimsLookupStrategies =
+                optionalClaimsLookupStrategiesLookupStrategy.apply(profileRequestContext);
+        if (optionalClaimsLookupStrategies != null) {
+            for (final String claim : optionalClaimsLookupStrategies.keySet()) {
+                log.trace("{} Looking up the value for clain {}", getLogPrefix(), claim);
+                final Function<ProfileRequestContext,Object> lookup = optionalClaimsLookupStrategies.get(claim);
+                final Object value = lookup.apply(profileRequestContext);
+                if (value != null) {
+                    log.debug("{} Resolved value {} for clain {}", getLogPrefix(), value, claim);
+                    builder.claim(claim, value);
+                } else {
+                    log.debug("{} No value resolved for clain {}", getLogPrefix(), claim);
+                }
+            }
+        }
+
+       return true;
+   }
+
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/DefaultTrustMarkFromMetadataCacheFetchingFunction.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/DefaultTrustMarkFromMetadataCacheFetchingFunction.java
new file mode 100644
index 0000000..79e225e
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/DefaultTrustMarkFromMetadataCacheFetchingFunction.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.oidfed.metadata.TrustMark;
+import net.shibboleth.oidfed.metadata.cache.FederationEndpointEntityStatementCriterion;
+import net.shibboleth.oidfed.metadata.cache.SubjectEntityIDCriterion;
+import net.shibboleth.oidfed.metadata.cache.configuration.EntityConfigurationContainer;
+import net.shibboleth.oidfed.metadata.cache.trustmark.TrustMarkCacheIdentifier;
+import net.shibboleth.oidfed.metadata.cache.trustmark.TrustMarkContainer;
+import net.shibboleth.oidfed.metadata.cache.trustmark.TrustMarkIdentifierCriterion;
+import net.shibboleth.oidfed.profile.TrustedRemoteEntity;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Function to fetch a trust mark from the configured cache of trust marks. The configurable cache is used for
+ * fetching the trust_mark_endpoint of the trusted entity.
+ */
+public class DefaultTrustMarkFromMetadataCacheFetchingFunction extends AbstractIdentifiableInitializableComponent
+    implements Function<ProfileRequestContext, Map<String, String>> {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(DefaultTrustMarkFromMetadataCacheFetchingFunction.class);
+
+    /** Cache used to fetch the issuer entity configuration from. */
+    @NonnullAfterInit private MetadataCache<EntityConfigurationContainer> entityConfigurationCache;
+
+    /** Cache containing responses from Trust Mark APIs. */
+    @NonnullAfterInit private MetadataCache<TrustMarkContainer> trustMarkCache;
+
+    /** Trusted trust mark API entity. */
+    @NonnullAfterInit private TrustedRemoteEntity trustedEntity;
+
+    /** Trust mark type. */
+    @NonnullAfterInit private String trustMarkType;
+
+    /** Subject of the trust mark. */
+    @NonnullAfterInit private String subject;
+
+    /**
+     * Set the cache used to fetch the issuer entity configuration from.
+     * 
+     * @param cache cache used to fetch the issuer entity configuration from
+     */
+    public void setEntityConfigurationCache(@Nonnull final MetadataCache<EntityConfigurationContainer> cache) {
+        checkSetterPreconditions();
+        entityConfigurationCache = Constraint.isNotNull(cache, "Entity Configuration cache cannot be null");
+    }
+
+    /**
+     * Set the cache containing responses from Trust Mark APIs.
+     * 
+     * @param cache cache containing responses from Trust Mark APIs
+     */
+    public void setTrustMarkCache(@Nonnull final MetadataCache<TrustMarkContainer> cache) {
+        checkSetterPreconditions();
+        trustMarkCache = Constraint.isNotNull(cache, "Trust Mark cache cannot be null");
+    }
+
+    /**
+     * Set the trusted trust mark API entity.
+     * 
+     * @param entity trusted trust mark API entity
+     */
+    public void setTrustedEntity(@Nonnull final TrustedRemoteEntity entity) {
+        checkSetterPreconditions();
+        trustedEntity = Constraint.isNotNull(entity, "Trusted entity cannot be null");
+    }
+
+    /**
+     * Set the trust mark type.
+     * 
+     * @param type trust mark type
+     */
+    public void setTrustMarkType(@Nonnull @NotEmpty final String type) {
+        checkSetterPreconditions();
+        trustMarkType = Constraint.isNotEmpty(type, "Trust mark type cannot be empty");
+    }
+
+    /**
+     * Set the subject.
+     * 
+     * @param sub subject
+     */
+    public void setSubject(@Nonnull @NotEmpty final String sub) {
+        checkSetterPreconditions();
+        subject = Constraint.isNotEmpty(sub, "Subject cannot be empty");
+    }
+
+    /** {@inheritDoc} */
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (entityConfigurationCache == null) {
+            throw new ComponentInitializationException("Entity configuration cache cannot be null");
+        }
+        if (trustMarkCache == null) {
+            throw new ComponentInitializationException("Trust Mark cache cannot be null");
+        }
+        if (trustedEntity == null) {
+            throw new ComponentInitializationException("Trusted entity cannot be null");
+        }
+        if (trustMarkType == null) {
+            throw new ComponentInitializationException("Trust mark type cannot be null");
+        }
+        if (subject == null) {
+            throw new ComponentInitializationException("Subject cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nullable
+    public Map<String, String> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        checkComponentActive();
+        final String entityId = trustedEntity.getEntityId();
+        assert entityConfigurationCache != null;
+        final CriteriaSet criteria = new CriteriaSet(new SubjectEntityIDCriterion(entityId));
+        final EntityConfiguration configuration;
+        try {
+            final List<EntityConfigurationContainer> result = entityConfigurationCache.get(criteria);
+            if (!result.isEmpty()) {
+                configuration = Optional.ofNullable(result.get(0).getStatement()).orElse(null);
+            } else {
+                return null;
+            }
+        } catch (final MetadataCacheException e) {
+            log.debug("Error while fetching entity configuration for {}", entityId, e);
+            return null;
+        }
+
+        if (configuration == null) {
+            log.warn("Could not fetch entity configuration for {}", entityId);
+            return null;
+        }
+
+        final URI uri = Optional.ofNullable(configuration.getParsedPayload().getMetadata())
+                .map(metadata -> metadata.getFederationEntityMetadata())
+                .map(map -> map.get("trust_mark_endpoint"))
+                .filter(String.class::isInstance)
+                .map(String.class::cast)
+                .map(URI::create)
+                .orElse(null);
+
+        if (uri == null) {
+            log.warn("Could not fetch trust mark endpoint for {}", entityId);
+            return null;
+        }
+        final String uriValue = uri.toString();
+        assert uriValue != null; assert trustMarkType != null; assert subject != null;
+        final TrustMarkCacheIdentifier trustMarkIdentifier =
+                new TrustMarkCacheIdentifier(uriValue, trustMarkType, subject);
+        final CriteriaSet criteriaSet = new CriteriaSet(new TrustMarkIdentifierCriterion(trustMarkIdentifier),
+                new FederationEndpointEntityStatementCriterion(configuration));
+        final List<TrustMarkContainer> cacheResult;
+        try {
+            cacheResult = trustMarkCache.get(criteriaSet);
+        } catch (final MetadataCacheException e) {
+            log.warn("Could not resolve trust mark {} from {}", trustMarkType, trustedEntity, e);
+            return null;
+        }
+        if (cacheResult.isEmpty()) {
+            log.debug("No data resolved for {} from {}", trustMarkType, trustedEntity);
+            return null;
+        }
+        final TrustMark trustMark = cacheResult.get(0).getStatement();
+        if (trustMark != null) {
+            return Map.of("trust_mark_type", trustMarkType, "trust_mark", trustMark.getJwt().serialize());
+        } else {
+            log.debug("The cache container for {} did not contain trust mark", trustedEntity);
+        }
+        return null;
+    }
+}
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementContext.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementContext.java
new file mode 100644
index 0000000..c4e4091
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementContext.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jwt.JWT;
+
+import net.shibboleth.oidfed.metadata.payload.claim.Metadata;
+
+/**
+ * Subcontext carrying information used to produce entity statements.
+ */
+public final class EntityStatementContext extends BaseContext {
+
+    /** Metadata. */
+    @Nullable private Metadata metadata;
+
+    /** Lifetime of the statement. */
+    @Nullable private Duration lifetime;
+
+    /** The entity statement. */
+    @Nullable private JWT jwt;
+
+    /** The keys claim for the entity statement. */
+    @Nullable private JWKSet keys;
+
+    /**
+     * Get the metadata.
+     * 
+     * @return the metadata
+     */
+    @Nullable public Metadata getMetadata() {
+        return metadata;
+    }
+
+    /**
+     * Set the metadata.
+     * 
+     * @param data the metadata
+     * 
+     * @return this context
+     */
+    @Nonnull public EntityStatementContext setMetadata(@Nullable final Metadata data) {
+        metadata = data;
+        return this;
+    }
+    
+    /**
+     * Get the entity statement JWT.
+     * 
+     * <p>May be in various states prior to signing.</p>
+     * 
+     * @return the JWT
+     */
+    @Nullable public JWT getJWT() {
+        return jwt;
+    }
+
+    /**
+     * Set the entity statement JWT.
+     * 
+     * <p>May be in various states prior to signing.</p>
+     * 
+     * @param token the JWT
+     * 
+     * @return this context
+     */
+    @Nonnull public EntityStatementContext setJWT(@Nullable final JWT token) {
+        jwt = token;
+        return this;
+    }
+    
+    /**
+     * Get the statement lifetime.
+     * 
+     * @return lifetime
+     */
+    @Nullable public Duration getLifetime() {
+        return lifetime;
+    }
+    
+    /**
+     * Set the statement lifetime.
+     * 
+     * @param lt lifetime
+     * 
+     * @return this context
+     */
+    @Nonnull public EntityStatementContext setLifetime(@Nullable final Duration lt) {
+        lifetime = lt;
+        return this;
+    }
+
+    /**
+     * Get the keys claim for the entity statement.
+     * 
+     * @return keys
+     */
+    @Nullable public JWKSet getKeys() {
+        return keys;
+    }
+
+    /**
+     * Set the keys claim for the entity statement.
+     * 
+     * @param jwks keys
+     * 
+     * @return this context
+     */
+    @Nonnull public EntityStatementContext setKeys(@Nullable final JWKSet jwks) {
+        keys = jwks;
+        return this;
+    }
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementUpdateStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementUpdateStrategy.java
new file mode 100644
index 0000000..514bf37
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/EntityStatementUpdateStrategy.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+
+import com.nimbusds.jwt.JWT;
+
+/**
+ * Add the {@link JWT} back to the {@link EntityStatementContext}.
+ */
+public class EntityStatementUpdateStrategy implements BiConsumer<JWT, MessageContext> {
+
+    /** Strategy used to locate the subcontext with the statement. */
+    @Nonnull private Function<MessageContext,EntityStatementContext> entityStatementContextLookupStrategy;
+
+    /**
+     * Constructor.
+     */
+    public EntityStatementUpdateStrategy() {
+        final Function<MessageContext,EntityStatementContext> escls =
+                new ChildContextLookup<>(EntityStatementContext.class);
+        assert escls != null;
+        entityStatementContextLookupStrategy = escls;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void accept(final JWT jwt, final MessageContext messageContext) {
+        if (messageContext == null) {
+            return;
+        }
+        final EntityStatementContext entityStatementCtx = entityStatementContextLookupStrategy.apply(messageContext);
+        if (entityStatementCtx == null) {
+            return;
+        }
+        entityStatementCtx.setJWT(jwt);
+    }
+}
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/FormOutboundFederationConfigurationResponse.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/FormOutboundFederationConfigurationResponse.java
new file mode 100644
index 0000000..9d9bc6a
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/FormOutboundFederationConfigurationResponse.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.SignedJWT;
+import com.nimbusds.oauth2.sdk.Response;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.messaging.impl.EntityConfigurationResponse;
+import net.shibboleth.oidfed.metadata.cache.ResponseContainerExpirationCriterion;
+import net.shibboleth.oidfed.metadata.cache.local.NimbusResponseContainer;
+import net.shibboleth.oidfed.metadata.cache.local.NimbusResponseCriterion;
+import net.shibboleth.oidfed.profile.config.navigate.CachedSuccessResponseLifetimeLookupFunction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * This action builds a response for the OpenID federation configuration request. The response contains an
+ * {@link SignedJWT} obtained from {@link EntityStatementContext#getJWT()}.
+ */
+public class FormOutboundFederationConfigurationResponse extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(FormOutboundFederationConfigurationResponse.class);
+
+    /** Metadata cache for cached response containers. */
+    @NonnullAfterInit private MetadataCache<NimbusResponseContainer> responseCache;
+
+    /** Strategy used to locate the cached message context. */
+    @Nonnull
+    private Function<ProfileRequestContext, RelyingPartyCachedMessageContext> cachedMessageContextLookupStrategy;
+
+    /** Strategy used to locate the subcontext to hold the statement. */
+    @Nonnull private Function<ProfileRequestContext,EntityStatementContext> entityStatementContextLookupStrategy;
+
+    /** Strategy used to locate the lifetime for the cached response record. */
+    @Nonnull private Function<ProfileRequestContext,Duration> cachedResponseLifetimeLookupStrategy;
+
+    /** JWT used to build entity statement. */
+    @Nullable private SignedJWT jwt;
+
+    /** The resolve entity context to operate on. */
+    @NonnullBeforeExec private RelyingPartyCachedMessageContext cachedMessageContext;
+
+    /**
+     * Constructor.
+     */
+    public FormOutboundFederationConfigurationResponse() {
+        final Function<ProfileRequestContext,EntityStatementContext> escls =
+                new ChildContextLookup<>(EntityStatementContext.class).compose(
+                        new OutboundMessageContextLookup());
+        assert escls != null;
+        entityStatementContextLookupStrategy = escls;
+        cachedMessageContextLookupStrategy = new ChildContextLookup<>(RelyingPartyCachedMessageContext.class);
+        cachedResponseLifetimeLookupStrategy = new CachedSuccessResponseLifetimeLookupFunction();
+    }
+
+    /**
+     * Set the strategy used to locate the subcontext to hold the statement
+     * 
+     * @param strategy What to set.
+     */
+    public void setEntityStatementContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,EntityStatementContext> strategy) {
+        checkSetterPreconditions();
+        entityStatementContextLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null!");
+    }
+
+    /**
+     * Set the strategy used to locate the cached message context
+     * 
+     * @param strategy What to set.
+     */
+    public void setCachedMessageContextLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext,RelyingPartyCachedMessageContext> strategy) {
+        checkSetterPreconditions();
+        cachedMessageContextLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null!");
+    }
+
+    /**
+     * Set the metadata cache for cached response containers.
+     * 
+     * @param cache What to set.
+     */
+    public void setResponseCache(@Nonnull final MetadataCache<NimbusResponseContainer> cache) {
+        checkSetterPreconditions();
+        responseCache = Constraint.isNotNull(cache, "Response metadata cache cannot be null");
+    }
+
+    /**
+     * Set the strategy used to locate the lifetime for the cached response record.
+     * 
+     * @param strategy What to set.
+     */
+    public void setCachedResponseLifetimeLookupStrategy(
+            @Nonnull final Function<ProfileRequestContext, Duration> strategy) {
+        checkSetterPreconditions();
+        cachedResponseLifetimeLookupStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (responseCache == null) {
+            throw new ComponentInitializationException("Response metadata cache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+        cachedMessageContext = cachedMessageContextLookupStrategy.apply(profileRequestContext);
+        if (cachedMessageContext == null) {
+            log.error("{} Could not resolve cached message context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final Response cachedResponse = cachedMessageContext.getCachedResponse();
+        if (cachedResponse != null) {
+            log.debug("{} Cached response found, storing in to the outbound message context", getLogPrefix());
+            profileRequestContext.ensureOutboundMessageContext().setMessage(cachedResponse);
+            return;
+        }
+        log.debug("{} No cached response found, resolving the response JWT from the context", getLogPrefix());
+        final EntityStatementContext entityStatementContext =
+                entityStatementContextLookupStrategy.apply(profileRequestContext);
+        if (entityStatementContext == null) {
+            log.error("{} Could not resolve entity statement context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        if (entityStatementContext.getJWT() instanceof SignedJWT signedJwt) {
+            jwt = signedJwt;
+        } else {
+            log.error("{} No signed JWT found from the entity statement context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+
+        assert jwt != null;
+        final EntityConfigurationResponse response = new EntityConfigurationResponse(jwt);
+        final NimbusResponseCriterion responseCriterion = new NimbusResponseCriterion(response);
+        final Duration lifetime = cachedResponseLifetimeLookupStrategy.apply(profileRequestContext);
+        if (lifetime == null) {
+            log.error("{} Could not resolve lifetime for the cached response record", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return;
+        }
+        final Instant expiration = Instant.now().plus(lifetime);
+        assert expiration != null;
+        final ResponseContainerExpirationCriterion expirationCriterion =
+                new ResponseContainerExpirationCriterion(expiration);
+        final CriteriaSet criteria = new CriteriaSet(responseCriterion, expirationCriterion);
+        try {
+            final List<NimbusResponseContainer> result = responseCache.get(criteria);
+            if (result.size() != 1) {
+                log.error("{} Unexpected result (size={}) when storing response record into the metadata cache",
+                        getLogPrefix(), result.size());
+            } else {
+                log.debug("{} Response stored into the cache", getLogPrefix());
+            }
+        } catch (final MetadataCacheException e) {
+            log.error("{} Could not store the response record into tht metadata cache", getLogPrefix(), e);
+        }
+
+        profileRequestContext.ensureOutboundMessageContext().setMessage(response);
+    }
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/InitializeEntityStatementContext.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/InitializeEntityStatementContext.java
new file mode 100644
index 0000000..08129cf
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/InitializeEntityStatementContext.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidfed.metadata.payload.claim.impl.MetadataImpl;
+import net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecorator;
+import net.shibboleth.oidfed.profile.EntityConfigurationMetadataDecoratorManager;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.NonnullSupplier;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+
+/**
+ * Action that creates and initializes the {@link EntityStatementContext}.
+ *
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link EventIds#IO_ERROR}
+ */
+public class InitializeEntityStatementContext extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(InitializeEntityStatementContext.class);
+
+    /** Strategy used to locate skeleton for the metadata claim. */
+    @NonnullAfterInit
+    private Function<CriteriaSet,Map<String,Map<String,Object>>> metadataSkeletonLookupStrategy;
+
+    /** Strategy used to create the subcontext to hold the statement. */
+    @Nonnull private Function<ProfileRequestContext,EntityStatementContext> entityStatementContextCreationStrategy;
+
+    /** Manager for entity configuration metadata decorators. */
+    @NonnullAfterInit private EntityConfigurationMetadataDecoratorManager metadataDecoratorManager;
+
+    /** Entity statement context. */
+    @NonnullBeforeExec private EntityStatementContext entityStatementCtx;
+
+    /** Metadata skeleton. */
+    @NonnullBeforeExec private Map<String,Map<String,Object>> metadataSkeleton;
+
+    /** Constructor. */
+    public InitializeEntityStatementContext() {
+        final Function<ProfileRequestContext,EntityStatementContext> esccs =
+                new ChildContextLookup<>(EntityStatementContext.class, true).compose(
+                        new OutboundMessageContextLookup());
+        assert esccs != null;
+        entityStatementContextCreationStrategy = esccs; 
+    }
+
+    /**
+     * Set the strategy used to locate skeleton for the metadata claim.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setMetadataSkeletonLookupStrategy(
+            @Nonnull final Function<CriteriaSet,Map<String,Map<String,Object>>> strategy) {
+        checkSetterPreconditions();
+        
+        metadataSkeletonLookupStrategy =
+                Constraint.isNotNull(strategy, "MetadataSkeletonLookupStrategy cannot be null");
+    }
+
+    /**
+     * Set the strategy used to create the {@link EntityStatementContext} to use.
+     * 
+     * @param strategy creation strategy
+     */
+    public void setEntityStatementContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,EntityStatementContext> strategy) {
+        checkSetterPreconditions();
+        
+        entityStatementContextCreationStrategy =
+                Constraint.isNotNull(strategy, "EntityStatementContextCreationStrategy cannot be null");
+    }
+
+    /**
+     * Set the manager for entity configuration metadata decorators.
+     * 
+     * @param manager metadata decorator manager
+     */
+    public void setMetadataDecoratorManager(@Nonnull final EntityConfigurationMetadataDecoratorManager manager) {
+        checkSetterPreconditions();
+
+        metadataDecoratorManager = Constraint.isNotNull(manager, "MetadataDecoratorManager cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (metadataSkeletonLookupStrategy == null) {
+            throw new ComponentInitializationException("MetadataSkeletonLookupStrategy cannot be null");
+        }
+        if (metadataDecoratorManager == null) {
+            throw new ComponentInitializationException("MetadataDecoratorManager cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        entityStatementCtx = entityStatementContextCreationStrategy.apply(profileRequestContext);
+        if (entityStatementCtx == null) {
+            log.error("{} Unable to create EntityStatementContext", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+
+        metadataSkeleton = new HashMap<>(Optional.ofNullable(metadataSkeletonLookupStrategy.apply(new CriteriaSet()))
+                .orElseGet(NonnullSupplier.of(CollectionSupport.emptyMap())));
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        log.trace("{} Start decoration of metadata skeleton {}", getLogPrefix(), metadataSkeleton);
+        for (final EntityConfigurationMetadataDecorator decorator : metadataDecoratorManager.all()) {
+            log.debug("{} Decorating metadata skeleton with {}", getLogPrefix(), decorator.getId());
+            decorator.accept(metadataSkeleton, profileRequestContext);
+        }
+        log.trace("{} Metadata after all decorators have run: {}", getLogPrefix(), metadataSkeleton);
+        assert metadataSkeleton != null;
+        entityStatementCtx.setMetadata(new MetadataImpl(metadataSkeleton));
+    }
+
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/JWTClaimsSetFromEntityStatementLookupFunction.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/JWTClaimsSetFromEntityStatementLookupFunction.java
new file mode 100644
index 0000000..bec62a8
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/JWTClaimsSetFromEntityStatementLookupFunction.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.text.ParseException;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.MessageContext;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.slf4j.Logger;
+
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/** 
+ * Extract the {@link JWTClaimsSet} from the JWT in {@link EntityStatementContext}.
+ */
+public class JWTClaimsSetFromEntityStatementLookupFunction implements Function<MessageContext, JWTClaimsSet> {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(JWTClaimsSetFromEntityStatementLookupFunction.class);
+
+    /** Strategy used to locate the subcontext with the token. */
+    @Nonnull private Function<MessageContext,EntityStatementContext> entityStatementContextLookupStrategy;
+
+    /**
+     * Constructor.
+     */
+    public JWTClaimsSetFromEntityStatementLookupFunction() {
+        // message context -> OIDC response context -> ATC
+        final Function<MessageContext,EntityStatementContext> escl = new ChildContextLookup<>(EntityStatementContext.class);
+        assert escl != null;
+        entityStatementContextLookupStrategy = escl;
+    }
+    
+    /**
+     * Set the strategy used to lookup the {@link EntityStatementContext} to use.
+     * 
+     * @param strategy lookup strategy
+     */
+    public void setEntityStatementContextCreationStrategy(
+            @Nonnull final Function<MessageContext,EntityStatementContext> strategy) {
+        entityStatementContextLookupStrategy =
+                Constraint.isNotNull(strategy, "EntityStatementContext lookup strategy cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nullable public JWTClaimsSet apply(@Nullable final MessageContext messageContext) {
+        if (messageContext == null) {
+            return null;
+        }
+        final EntityStatementContext entityStatementCtx = entityStatementContextLookupStrategy.apply(messageContext);
+        if (entityStatementCtx == null) {
+            return null;
+        }
+        final JWT jwt = entityStatementCtx.getJWT();
+        try {
+            if (jwt != null) {
+                return jwt.getJWTClaimsSet();
+            }
+        } catch (final ParseException e) {
+            log.error("Could not fetch the claims set from entity statement", e);
+        }
+        return null;
+    }
+}
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/LookupCachedNimbusResponse.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/LookupCachedNimbusResponse.java
new file mode 100644
index 0000000..ff390fa
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/LookupCachedNimbusResponse.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.cache.local.NimbusResponseContainer;
+import net.shibboleth.oidfed.profile.OidFederationEventIds;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Lookup if a cached response already exists for entity statement. If yes, the response is
+ * stored into {@link RelyingPartyCachedMessageContext} and a corresponding event ID is published.
+ * 
+ * @event {@link EventIds#PROCEED_EVENT_ID}
+ * @event {@link EventIds#INVALID_PROFILE_CTX}
+ * @event {@link OidFederationEventIds#CACHED_RESPONSE_FOUND}
+ */
+public class LookupCachedNimbusResponse extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(LookupCachedNimbusResponse.class);
+
+    /** Strategy used to create the cached message context. */
+    @Nonnull
+    private Function<ProfileRequestContext, RelyingPartyCachedMessageContext> cachedMessageContextCreationStrategy;
+
+    /** Metadata cache for cached response containers. */
+    @NonnullAfterInit private MetadataCache<NimbusResponseContainer> responseCache;
+
+    /** Cached message context to operate on. */
+    @NonnullBeforeExec private RelyingPartyCachedMessageContext cachedMessageContext;
+
+    /**
+     * Constructor.
+     */
+    public LookupCachedNimbusResponse() {
+        final Function<ProfileRequestContext, RelyingPartyCachedMessageContext> recls =
+                new ChildContextLookup<>(RelyingPartyCachedMessageContext.class, true);
+        assert recls != null;
+        cachedMessageContextCreationStrategy = recls;
+    }
+
+    /**
+     * Set the strategy used to create the cached message context
+     * 
+     * @param strategy What to set.
+     */
+    public void setCachedMessageContextCreationStrategy(
+            @Nonnull final Function<ProfileRequestContext,RelyingPartyCachedMessageContext> strategy) {
+        checkSetterPreconditions();
+        cachedMessageContextCreationStrategy = Constraint.isNotNull(strategy, "Lookup strategy cannot be null!");
+    }
+
+    /**
+     * Set the metadata cache for cached response containers.
+     * 
+     * @param cache What to set.
+     */
+    public void setResponseCache(@Nonnull final MetadataCache<NimbusResponseContainer> cache) {
+        checkSetterPreconditions();
+        responseCache = Constraint.isNotNull(cache, "Response metadata cache cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        if (responseCache == null) {
+            throw new ComponentInitializationException("Response metadata cache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        cachedMessageContext = cachedMessageContextCreationStrategy.apply(profileRequestContext);
+        if (cachedMessageContext == null) {
+            log.error("{} Could not create cached response context", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+            return false;
+        }
+        
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+        final CriteriaSet criteria = new CriteriaSet();
+        try {
+            final List<NimbusResponseContainer> result = responseCache.get(criteria);
+            if (result.size() != 1) {
+                log.debug("{} No cached response record found from the metadata cache", getLogPrefix(), result.size());
+            } else {
+                final NimbusResponseContainer cachedResponse = result.get(0);
+                cachedMessageContext.setCachedResponse(cachedResponse.getResponse());
+                log.debug("{} Response found from the cache, publishing event {}", getLogPrefix(),
+                        OidFederationEventIds.CACHED_RESPONSE_FOUND);
+                ActionSupport.buildEvent(profileRequestContext,
+                        OidFederationEventIds.CACHED_RESPONSE_FOUND);
+                return;
+            }
+        } catch (final MetadataCacheException e) {
+            log.error("{} Could not fetch response record from the metadata cache", getLogPrefix(), e);
+        }
+   }
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/RelyingPartyCachedMessageContext.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/RelyingPartyCachedMessageContext.java
new file mode 100644
index 0000000..66226fe
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/impl/RelyingPartyCachedMessageContext.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.impl;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.BaseContext;
+
+import com.nimbusds.oauth2.sdk.Request;
+import com.nimbusds.oauth2.sdk.Response;
+
+/**
+ * Subcontext carrying information for request and response messages related to a relying party.
+ */
+public final class RelyingPartyCachedMessageContext extends BaseContext {
+
+    /** Validated (possibly modified) request message. */
+    @Nullable private Request validatedRequest;
+
+    /** Cached response message. */
+    @Nullable private Response cachedResponse;
+
+    /**
+     * Get the validated (possibly modified) resolve entity request.
+     * 
+     * @return the validated request
+     */
+    @Nullable public Request getValidatedRequest() {
+        return validatedRequest;
+    }
+
+    /**
+     * Set the the validated (possibly modified) resolve entity request.
+     * 
+     * @param request the validated request
+     * @return this context
+     */
+    @Nonnull public RelyingPartyCachedMessageContext setValidatedRequest(@Nullable final Request request) {
+        validatedRequest = request;
+        return this;
+    }
+
+    /**
+     * Get the cached response message.
+     * 
+     * @return the cached response
+     */
+    @Nullable public Response getCachedResponse() {
+        return cachedResponse;
+    }
+
+    /**
+     * Set the cached response message.
+     * 
+     * @param response cached response
+     * @return this context
+     */
+    @Nonnull public RelyingPartyCachedMessageContext setCachedResponse(@Nullable final Response response) {
+        cachedResponse = response;
+        return this;
+    }
+}
\ No newline at end of file
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultEntityConfigurationTrustMarksLookupStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultEntityConfigurationTrustMarksLookupStrategy.java
new file mode 100644
index 0000000..d207f6b
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultEntityConfigurationTrustMarksLookupStrategy.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.navigate;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Default strategy to fetch trust marks to be included in the entity configuration.
+ */
+public class DefaultEntityConfigurationTrustMarksLookupStrategy extends AbstractIdentifiableInitializableComponent
+    implements Function<ProfileRequestContext,List<Map<String,String>>> {
+
+    /** Lookup strategies to fetch trust mark values to be included in the entity configuration. */
+    @Nonnull private List<Function<ProfileRequestContext,Map<String,String>>> trustMarkLookupStrategies;
+
+    /**
+     * Constructor.
+     */
+    public DefaultEntityConfigurationTrustMarksLookupStrategy() {
+        trustMarkLookupStrategies = CollectionSupport.emptyList();
+    }
+
+    /**
+     * Set the lookup strategies to fetch trust mark values to be included in the entity configuration.
+     * 
+     * @param strategies lookup strategies
+     */
+    public void setTrustMarkLookupStrategies(
+            @Nonnull final List<Function<ProfileRequestContext,Map<String,String>>> strategies) {
+        checkSetterPreconditions();
+        Constraint.isNotNull(strategies, "Trust mark lookup strategies cannot be null");
+        trustMarkLookupStrategies = strategies;
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nonnull
+    public List<Map<String, String>> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        checkComponentActive();
+        final List<Map<String, String>> trustMarks = new ArrayList<>();
+        for (final Function<ProfileRequestContext,Map<String,String>> strategy : trustMarkLookupStrategies) {
+            if (strategy == null) {
+                continue;
+            }
+            Optional.ofNullable(strategy.apply(profileRequestContext))
+                .filter(trustMark -> trustMark != null && !trustMark.isEmpty())
+                .ifPresent(trustMark -> trustMarks.add(trustMark));
+            
+        }
+        return CollectionSupport.copyToList(trustMarks);
+    }
+
+}
diff --git a/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultTrustAnchorHintsLookupStrategy.java b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultTrustAnchorHintsLookupStrategy.java
new file mode 100644
index 0000000..a72bd62
--- /dev/null
+++ b/oidfed-common-impl/src/main/java/net/shibboleth/oidfed/profile/navigate/DefaultTrustAnchorHintsLookupStrategy.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed 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 net.shibboleth.oidfed.profile.navigate;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+import net.shibboleth.oidfed.metadata.cache.local.LocalKeyContainer;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.CriteriaSet;
+
+/**
+ * Default strategy to fetch value for the trust_amchor_hints -claim. The value is fetched from the configurable
+ * {@link MetadataCache} containing trusted trust anchors.
+ */
+public class DefaultTrustAnchorHintsLookupStrategy extends AbstractIdentifiableInitializableComponent
+        implements Function<ProfileRequestContext,List<String>> {
+
+    /** Class logger. */
+    @Nonnull private Logger log = LoggerFactory.getLogger(DefaultTrustAnchorHintsLookupStrategy.class);
+
+    /** Cache containing trusted trust anchors. */
+    @NonnullAfterInit private MetadataCache<Map<String, LocalKeyContainer>> trustAnchorsCache;
+
+    /**
+     * Set the cache containing trusted trust anchors.
+     * 
+     * @param cache trust anchors cache
+     */
+    public void setTrustAnchorsCache(
+            @Nonnull final MetadataCache<Map<String, LocalKeyContainer>> cache) {
+        checkSetterPreconditions();
+        trustAnchorsCache = Constraint.isNotNull(cache, "Trust Anchors cache cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+        
+        if (trustAnchorsCache == null) {
+            throw new ComponentInitializationException("Trust Anchors cache cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override @Nullable
+    public List<String> apply(@Nullable final ProfileRequestContext profileRequestContext) {
+        checkComponentActive();
+        final List<Map<String, LocalKeyContainer>> keyContainers;
+        try {
+            keyContainers = trustAnchorsCache.get(new CriteriaSet());
+        } catch (final MetadataCacheException e) {
+            log.warn("Could not resolve any trust anchors", e);
+            return null;
+        }
+        if (keyContainers.isEmpty()) {
+            log.debug("No keycontainers returned from the trust anchor cache");
+            return null;
+        }
+        return keyContainers.get(0).keySet().stream().filter(Objects::nonNull).toList();
+    }
+}

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list