[java-idp-plugin-oidc-op-oidfed] 03/03: Improved and simplified the default trust chain fetching strategy

Codeberg noreply at shibboleth.net
Thu Jan 22 11:41:12 UTC 2026


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

codeberg pushed a commit to branch dev/CACHE-REFACTOR
in repository java-idp-plugin-oidc-op-oidfed.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-oidc-op-oidfed/commit/ea58669e0b2f649e126c46a4b9e75c76e002ca66

commit ea58669e0b2f649e126c46a4b9e75c76e002ca66
Author: Henri Mikkonen <henri.mikkonen at iki.fi>
AuthorDate: Thu Jan 22 13:40:45 2026 +0200

    Improved and simplified the default trust chain fetching strategy
    
    - The function now recognizes locally trusted intermediate authorities with broken authority hints
      - Previously solely entity configurations without authority hints were considered as trust anchors
      - The locally trusted intermediate authorities were parsed at later stage
      - Included initial (not complete) unit tests
---
 .../DefaultTrustChainFetchingStrategy.java         | 284 ++++++++-------
 .../metadata/util/EntityStatementHelper.java       |  32 ++
 .../DefaultTrustChainFetchingStrategyTest.java     | 380 +++++++++++++++++++++
 .../flow/oidfed/AbstractFederationFlowTest.java    |  19 +-
 .../flow/oidfed/EntityConfigurationFlowTest.java   |   2 +-
 .../oidfed/cache/TrustChainMetadataCacheTest.java  |  46 +++
 .../credentials/fed-local-intermediate.jwk         |  12 +
 .../module/conf/oidfed/oidfed-trust-anchors.json   |  11 +
 8 files changed, 661 insertions(+), 125 deletions(-)

diff --git a/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategy.java b/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategy.java
index 4b72d0c..6fed233 100644
--- a/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategy.java
+++ b/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategy.java
@@ -19,9 +19,7 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.function.Function;
-import java.util.stream.Stream;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -30,18 +28,18 @@ import org.slf4j.Logger;
 
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.EntityConfiguration;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.EntityStatement;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.SubordinateStatement;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.IssuerEntityIDCriterion;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.PreSelectedTrustChainCriterion;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.SubjectEntityIDCriterion;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.local.LocalKeyContainer;
-import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.BaseExpirableStatementContainer;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteEntityConfigurationContainer;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteSubordinateStatementContainer;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteTrustChainsContainer;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.SubjectEntityStatementCriterion;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.constraints.FederationPolicyConstraint;
 import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.constraints.FederationPolicyConstraintHelper;
-import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.payload.EntityConfigurationPayload;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.util.EntityStatementHelper;
 import net.shibboleth.oidc.metadata.cache.MetadataCache;
 import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
 import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
@@ -52,6 +50,7 @@ import net.shibboleth.shared.collection.Pair;
 import net.shibboleth.shared.component.AbstractIdentifiableInitializableComponent;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.ConstraintViolationException;
 import net.shibboleth.shared.primitive.LoggerFactory;
 import net.shibboleth.shared.resolver.CriteriaSet;
 
@@ -234,10 +233,7 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
                 if (containers.isEmpty()) {
                     return null;
                 }
-                entityConfiguration = Optional.ofNullable(containers.get(0).getStatement())
-                        .filter(EntityConfiguration.class::isInstance)
-                        .map(EntityConfiguration.class::cast)
-                        .orElse(null);
+                entityConfiguration = containers.get(0).getStatement();
             } catch (final MetadataCacheException e) {
                 log.error("Could not fetch entity configuration for the trust chain", e);
                 return null;
@@ -251,77 +247,24 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
             return null;
         }
 
-        final List<List<EntityStatement<?>>> rawChains = populateChain(
-                CollectionSupport.listOf(CollectionSupport.listOf(entityConfiguration)), preSelectedChain);
-        final List<List<EntityStatement<?>>> trustChains =
-                stripIntermediateConfigurations(entityConfiguration, rawChains);
-        final List<List<EntityStatement<?>>> result = Stream.concat(trustChains.stream(),
-                resolveLocallyTrustedTrustChains(trustChains).stream()).toList();
+        final TrustChainWrapper initialWrapper =
+                new TrustChainWrapper(CollectionSupport.listOf(entityConfiguration));
+        if (initialWrapper.isComplete()) {
+            log.warn("Entity configuration for {} does not contain authority hints, cannot resolve trust chains",
+                    entityConfiguration.getSubject());
+            return null;
+        }
+        final List<TrustChainWrapper> populatedWrappers = populateChain(
+                CollectionSupport.listOf(initialWrapper), preSelectedChain);
+        final List<List<EntityStatement<?>>> result = populatedWrappers.stream()
+                .filter(wrapper -> wrapper.isComplete())
+                .map(wrapper -> wrapper.getTrustChain())
+                .toList();
         assert result != null; assert invalidExpiration != null; assert validExpiration != null;
         return new RemoteTrustChainsContainer(result.isEmpty() ? invalidExpiration : validExpiration,
                 entityConfiguration.getSubject(), result);
     }
 
-    /**
-     * Strips the intermediate entity configurations out from the list of trust chains.
-     * 
-     * @param leaf the leaf entity configuration
-     * @param chains the list of trust chains to be filtered
-     * @return the filtered list of trust chains, intermediate configurations filtered out
-     */
-    @Nonnull
-    private List<List<EntityStatement<?>>> stripIntermediateConfigurations(@Nonnull final EntityStatement<?> leaf,
-            @Nonnull @NonnullElements final List<List<EntityStatement<?>>> chains) {
-        final List<List<EntityStatement<?>>> result = new ArrayList<>();
-        for (final List<EntityStatement<?>> chain : chains) {
-            result.add(chain.stream()
-                    .filter(es -> (es.equals(leaf)
-                        || !es.getSubject().equals(es.getIssuer())
-                        || (es.getParsedPayload() instanceof EntityConfigurationPayload ecp
-                                && ecp.getAuthorityHints() == null)))
-                    .toList());
-        }
-        return result;
-    }
-
-    /**
-     * Iterates the given trust chains and checks whether they contain locally trusted trust anchors as intermediate
-     * authorities. Such trust chains are returned in a way that locally trusted anchors are the trust anchors (i.e.
-     * the final items in the chain).
-     * 
-     * @param chains trust chains to be iterated over
-     * @return list of trust chains containing locally trusted trust anchors
-     */
-    @Nonnull private List<List<EntityStatement<?>>> resolveLocallyTrustedTrustChains(
-            @Nonnull @NonnullElements final List<List<EntityStatement<?>>> chains) {
-        final List<List<EntityStatement<?>>> result = new ArrayList<>();
-        for (final List<EntityStatement<?>> chain : chains) {
-            for (int i = 0; i < chain.size() - 1; i++) {
-                final EntityStatement<?> statement = chain.get(i);
-                assert statement != null;
-                final String entityId = chain.get(i).getSubject();
-                assert entityId != null;
-                if (isLocallyTrusted(statement) && !isTrustAnchor(entityId, result)) {
-                    try {
-                        final CriteriaSet criteria = new CriteriaSet(new SubjectEntityIDCriterion(entityId));
-                        final EntityStatement<?> localAnchorConfiguration =
-                                getFirstIfFound(entityConfigurationCache.get(criteria));
-                        if (localAnchorConfiguration != null) {
-                            final List<EntityStatement<?>> localTrustChain =
-                                    new ArrayList<>(chain.subList(0, chain.indexOf(statement)));
-                            localTrustChain.add(localAnchorConfiguration);
-                            result.add(CollectionSupport.copyToList(localTrustChain));
-                            log.debug("Included a trust chain based on a local trust anchor {}", entityId);
-                        }
-                    } catch (final MetadataCacheException e) {
-                        log.error("Could not resolve entity configuration for {}", entityId);
-                    }
-                }
-            }
-        }
-        return result;
-    }
-
     /**
      * Checks whether the given entity is locally trusted via local trust anchors cache.
      * 
@@ -343,24 +286,10 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
             log.trace("No locally trusted keys found for {}", entityId);
             return false;
         }
+        log.trace("Locally trusted keys configured for {}", entityId);
         return true;
     }
 
-    /**
-     * Checks whether the given entity is already a trust anchor in any of the given trust chains.
-     * 
-     * @param entityId the entity ID to be verified
-     * @param trustChains trust chains to be checked
-     * @return true if the entity is already a trust anchor, false otherwise
-     */
-    private boolean isTrustAnchor(@Nonnull final String entityId,
-            @Nonnull final List<List<EntityStatement<?>>> trustChains) {
-        return trustChains.stream()
-                .filter(chain -> entityId.equals(chain.get(chain.size() - 1).getSubject()))
-                .findAny()
-                .isPresent();
-    }
-
     /**
      * Recursively populates the given list of trust chains until the last entity statement in each chain doesn't
      * contain any authority hints.
@@ -369,21 +298,30 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
      * @param preSelectedChain the pre-selected trust chain (containing entity IDs as String).
      * @return the list of trust chains being populated
      */
-    @Nonnull @NonnullElements private List<List<EntityStatement<?>>> populateChain(
-            @Nonnull @NonnullElements final List<List<EntityStatement<?>>> entities,
+    @Nonnull @NonnullElements private List<TrustChainWrapper> populateChain(
+            @Nonnull @NonnullElements final List<TrustChainWrapper> entities,
             @Nonnull final List<String> preSelectedChain) {
-        final List<List<EntityStatement<?>>> result = new ArrayList<>();
+        final List<TrustChainWrapper> result = new ArrayList<>();
         boolean hints = false;
-        for (final List<EntityStatement<?>> chain : entities) {
+        for (final TrustChainWrapper chainWrapper : entities) {
+            if (chainWrapper.isComplete()) {
+                result.add(chainWrapper);
+                continue;
+            }
+            final List<EntityStatement<?>> chain = chainWrapper.getTrustChain();
             final EntityStatement<?> entityStatement = chain.get(chain.size() - 1);
-            final List<String> authorityHints =
-                    entityStatement.getParsedPayload() instanceof EntityConfigurationPayload ecp ?
-                            ecp.getAuthorityHints() : null;
-            if (authorityHints == null || authorityHints.isEmpty()) {
-                result.add(chain);
-            } else {
-                final List<Pair<EntityStatement<?>, EntityStatement<?>>> authorities = authorityHints.stream()
-                        .filter(id -> preSelectedChain.isEmpty() || preSelectedChain.contains(id))
+            assert entityStatement != null;
+            if (chain.size() > 2 && isLocallyTrusted(entityStatement)
+                    && matchesPreSelectedIfSet(chain, preSelectedChain)) {
+                result.add(chainWrapper.complete());
+            }
+            final List<String> authorityHints = entityStatement instanceof EntityConfiguration configuration ?
+                    configuration.getParsedPayload().getAuthorityHints() : null;
+            if (authorityHints != null && !authorityHints.isEmpty()) {
+                final List<Pair<EntityConfiguration, SubordinateStatement>> authorities = authorityHints.stream()
+                        .filter(id -> verifyNoLoop(chain, id))
+                        .filter(id -> verifyPreSelected(chain, id, preSelectedChain))
+                        .filter(id -> !EntityStatementHelper.subjectAlreadyExists(id, chain))
                         .map(id -> fetchAuthority(entityStatement, id))
                         .filter(pair -> pair != null && pair.getFirst() != null && pair.getSecond() != null)
                         .filter(pair -> FederationPolicyConstraintHelper.verifyPolicyConstraints(
@@ -391,10 +329,11 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
                         .toList();
                 hints = !authorities.isEmpty();
                 authorities.forEach(authority -> {
-                    final ArrayList<EntityStatement<?>> newChain = new ArrayList<>(chain);
+                    final ArrayList<EntityStatement<?>> newChain = new ArrayList<>(chain.size() > 2 ? 
+                            chain.subList(0, chain.size() - 1) : chain);
                     newChain.add(authority.getSecond());
                     newChain.add(authority.getFirst());
-                    result.add(newChain);
+                    result.add(new TrustChainWrapper(newChain));
                 });
             }
         }
@@ -404,6 +343,59 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
         return result;
     }
 
+    /**
+     * Verifies that the given authority is not already included in the given trust chain.
+     * 
+     * @param chain trust chain
+     * @param authority entity ID for authority
+     * @return true if not included, false otherwise
+     */
+    protected boolean verifyNoLoop(@Nonnull final List<EntityStatement<?>> chain, @Nullable final String authority) {
+        final List<String> entityIds = EntityStatementHelper.getEntityIds(chain);
+        if (entityIds.contains(authority)) {
+            log.warn("Loop detected: authority hint {} already in the trust chain {} resolved so far", authority,
+                    entityIds);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Verifies that the given authority is compliant with the pre-defined trust chain.
+     * 
+     * @param chain trust chain
+     * @param authority entity ID for authority
+     * @param preSelectedChain pre-defined trust chain
+     * @return true if authority is compliant, false otherwise
+     */
+    protected boolean verifyPreSelected(@Nonnull final List<EntityStatement<?>> chain, @Nullable final String authority,
+            @Nonnull final List<String> preSelectedChain) {
+        if (preSelectedChain.isEmpty()) {
+            return true;
+        }
+        if (preSelectedChain.size() <= chain.size() || !preSelectedChain.get(chain.size()).equals(authority)) {
+            log.debug("Ignoring authority {} as it doesn't match with the preselected chain {}", authority,
+                    preSelectedChain);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Checks if the given trust chain matches with the pre-selected trust chain if if it has been set.
+     * 
+     * @param chain trust chain
+     * @param preSelectedChain pre-selected trust chain entity IDs
+     * @return true if pre-selected trust chain is empty or is matching the given trust chain
+     */
+    protected boolean matchesPreSelectedIfSet(@Nonnull final List<EntityStatement<?>> chain,
+            @Nonnull final List<String> preSelectedChain) {
+        if (preSelectedChain.isEmpty()) {
+            return true;
+        }
+        return preSelectedChain.equals(EntityStatementHelper.getEntityIds(chain));
+    }
+
     /**
      * Fetch authority's entity configuration and subordinate statement for the given entity statement and
      * authority hint.
@@ -413,21 +405,24 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
      * @return pair consisting of authority's entity configuration and subordinate statement, or null if they could not
      *      be fetched.
      */
-    @Nullable protected Pair<EntityStatement<?>, EntityStatement<?>> fetchAuthority(
+    @Nullable protected Pair<EntityConfiguration, SubordinateStatement> fetchAuthority(
             @Nonnull final EntityStatement<?> entity, @Nullable final String authorityHint) {
         final String entityId = entity.getSubject();
         if (authorityHint == null) {
             log.error("Entity ID ({}) or authority hint ({}) may not be null", entityId, authorityHint);
             return null;
         }
-        final CriteriaSet criteria = new CriteriaSet(new SubjectEntityIDCriterion(entityId),
-                new IssuerEntityIDCriterion(authorityHint));
 
         try {
-            final EntityStatement<?> authorityConfiguration = getFirstIfFound(
-                    entityConfigurationCache.get(new CriteriaSet(new SubjectEntityIDCriterion(authorityHint))));
-            final EntityStatement<?> metadata = getFirstIfFound(subordinateStatementCache.get(criteria));
-            return new Pair<>(authorityConfiguration, metadata);
+            final List<RemoteEntityConfigurationContainer> authorityConfigurations = 
+                    entityConfigurationCache.get(new CriteriaSet(new SubjectEntityIDCriterion(authorityHint)));
+            log.trace("Fetched comfigurations {} for authority {}", authorityConfigurations, authorityHint);
+            final List<RemoteSubordinateStatementContainer> subordinateStatements =
+                    subordinateStatementCache.get(new CriteriaSet(new SubjectEntityIDCriterion(entityId),
+                            new IssuerEntityIDCriterion(authorityHint)));
+            log.trace("Fetched subordinate statements {} for authority {}", subordinateStatements, authorityHint);
+            return new Pair<>(authorityConfigurations.isEmpty() ? null : authorityConfigurations.get(0).getStatement(),
+                    subordinateStatements.isEmpty() ? null : subordinateStatements.get(0).getStatement());
         } catch (final MetadataCacheException e) {
             log.error("Could not resolve authority hint {} for {}", authorityHint, entityId);
             return null;
@@ -435,17 +430,66 @@ public class DefaultTrustChainFetchingStrategy extends AbstractIdentifiableIniti
     }
 
     /**
-     * Get the first entity statement from the given list if it's not empty.
-     * 
-     * @param statements list of entity statements
-     * @return first entity statemenet, or null the list was empty.
+     * Wrapper class containing trust chain (potentially under construction) and a flag for indicating whether it's
+     * complete.
      */
-    @Nullable private EntityStatement<?> getFirstIfFound(
-            @Nonnull final List<? extends BaseExpirableStatementContainer<?>> statements) {
-        if (statements.size() > 0 && statements.get(0).getStatement() instanceof EntityStatement<?> statement) {
-            return statement;
+    private class TrustChainWrapper {
+
+        /** Trust chain (potentially incomplete). */
+        private List<EntityStatement<?>> trustChain;
+
+        /** Flag indicating whether the trust chain is complete. */
+        private boolean complete;
+
+        /**
+         * Constructor.
+         *
+         * @param chain trust chain (potentially incomplete) - if the trust anchor (last item at the chain) does not
+         *              contain authority hints, the chain is considered as complete
+         * @throws ConstraintViolationException if the last item is not an {@link EntityConfiguration}
+         */
+        protected TrustChainWrapper(@Nonnull final List<EntityStatement<?>> chain)
+                throws ConstraintViolationException {
+            trustChain = chain;
+            if (chain.get(chain.size() - 1) instanceof EntityConfiguration entityConfiguration) {
+                final List<String> authorityHints = entityConfiguration.getParsedPayload().getAuthorityHints();
+                if (authorityHints == null || authorityHints.isEmpty()) {
+                    complete = true;
+                } else {
+                    complete = false;
+                }
+            } else {
+                throw new ConstraintViolationException("Unexpected entity statement type as last item: " 
+                        + chain.get(chain.size() - 1));
+            }
+        }
+
+        /**
+         * Get the trust chain.
+         * 
+         * @return trust chain
+         */
+        protected List<EntityStatement<?>> getTrustChain() {
+            return trustChain;
         }
-        return null;
-    }
 
+        /**
+         * Set this wrapper as complete.
+         * 
+         * @return this wrapper
+         */
+        protected TrustChainWrapper complete() {
+            complete = true;
+            return this;
+        }
+
+        /**
+         * Get whether this wrapper is complete.
+         * 
+         * @return true if complete, false otherwise
+         */
+        protected boolean isComplete() {
+            return complete;
+        }
+    }
 }
diff --git a/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/util/EntityStatementHelper.java b/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/util/EntityStatementHelper.java
index aac6274..d46e9a1 100644
--- a/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/util/EntityStatementHelper.java
+++ b/idp-oidfed-op-impl/src/main/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/util/EntityStatementHelper.java
@@ -16,6 +16,7 @@ package net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.util;
 
 import java.net.URI;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 import javax.annotation.Nonnull;
@@ -93,4 +94,35 @@ public class EntityStatementHelper {
         log.warn("Could not fetch entity configuration for {}", entityId);
         return null;
     }
+
+    /**
+     * Get the list of entity IDs of the entities in the given trust chain. The first item is the entity ID of the
+     * leaf entity.
+     * 
+     * @param trustChain trust chain
+     * @return list of entity IDs
+     */
+    @Nonnull public static List<String> getEntityIds(@Nonnull final List<EntityStatement<?>> trustChain) {
+        final List<String> result = trustChain.subList(1, trustChain.size()).stream()
+                .filter(Objects::nonNull)
+                .map(statement -> statement.getSubject())
+                .toList();
+        assert result != null;
+        return result;
+    }
+
+    /**
+     * Checks if the given subject already exists in the trust chain.
+     * 
+     * @param subject subject to be looked after
+     * @param trustChain trust chain
+     * @return true if subject already exists in the trust chain, false otherwise
+     */
+    public static boolean subjectAlreadyExists(@Nullable final String subject,
+            @Nonnull final List<EntityStatement<?>> trustChain) {
+        return subject == null ? false : trustChain.stream()
+                .filter(Objects::nonNull)
+                .map(statement -> statement.getSubject())
+                .anyMatch(id -> subject.equals(id));
+    }
 }
\ No newline at end of file
diff --git a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategyTest.java b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategyTest.java
new file mode 100644
index 0000000..d4f7571
--- /dev/null
+++ b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/oidfed/metadata/cache/remote/trustchain/DefaultTrustChainFetchingStrategyTest.java
@@ -0,0 +1,380 @@
+/*
+ * 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.remote.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.HashMap;
+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.idp.plugin.oidc.op.oidfed.metadata.EntityConfiguration;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.SubordinateStatement;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.PreSelectedTrustChainCriterion;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.local.LocalKeyContainer;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteEntityConfigurationContainer;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteSubordinateStatementContainer;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.RemoteTrustChainsContainer;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.cache.remote.SubordinateStatementCacheIdentifier;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.constraints.FederationPolicyConstraint;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.payload.EntityConfigurationPayload;
+import net.shibboleth.idp.plugin.oidc.op.oidfed.metadata.util.EntityStatementHelper;
+import net.shibboleth.oidc.metadata.cache.MetadataCache;
+import net.shibboleth.oidc.metadata.cache.MetadataCacheException;
+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<RemoteEntityConfigurationContainer> entityConfigurationCache;
+    MetadataCache<RemoteSubordinateStatementContainer> subordinateStatementCache;
+    MetadataCache<Map<String, LocalKeyContainer>> localTrustAnchorsCache;
+    Function<CriteriaSet, Duration> criteriaToValidContainerLifetimeStrategy;
+    Function<CriteriaSet, Duration> criteriaToInvalidContainerLifetimeStrategy;
+
+    @SuppressWarnings("unchecked")
+    @BeforeMethod
+    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);
+    }
+
+    public void setup(final Map<String, FederationPolicyConstraint> federationPolicyConstraints) {
+        function = new DefaultTrustChainFetchingStrategy();
+        function.setCriteriaToSubjectEntityIdStrategy(criteriaToSubjectEntityIdStrategy);
+        function.setEntityConfigurationCache(entityConfigurationCache);
+        function.setSubordinateStatementCache(subordinateStatementCache);
+        function.setLocalTrustAnchorsCache(localTrustAnchorsCache);
+        function.setCriteriaToValidContainerLifetimeStrategy(criteriaToValidContainerLifetimeStrategy);
+        function.setCriteriaToInvalidContainerLifetimeStrategy(criteriaToInvalidContainerLifetimeStrategy);
+        function.setFederationPolicyConstraints(federationPolicyConstraints);
+        function.setId("mockFunction");
+        try {
+            function.initialize();
+        } catch (ComponentInitializationException e) {
+            Assert.fail("Could not initialize the function", e);
+        }
+    }
+
+    @Test
+    public void nullCriteria_returnsNull() {
+        setup(new HashMap<>());
+        Assert.assertEquals(function.apply(null), null);
+    }
+
+    @Test
+    public void noValidLifetimeCriteria_returnsNull() {
+        setup(new HashMap<>());
+        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() {
+        setup(new HashMap<>());
+        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 {
+        setup(new HashMap<>());
+        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 {
+        setup(new HashMap<>());
+        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 {
+        setup(new HashMap<>());
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final RemoteEntityConfigurationContainer 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 {
+        setup(new HashMap<>());
+        when(criteriaToValidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        when(criteriaToInvalidContainerLifetimeStrategy.apply(any())).thenReturn(Duration.ofMinutes(5));
+        final RemoteEntityConfigurationContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final RemoteEntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final RemoteSubordinateStatementContainer ssContainer = ssContainer(leaf, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        final RemoteTrustChainsContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(immediate, "https://federation.local/other"));
+        final RemoteEntityConfigurationContainer immediateContainer =
+                ecContainer(immediate, CollectionSupport.listOf(anchor));
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(immediateContainer),
+                    CollectionSupport.emptyList());
+        final RemoteSubordinateStatementContainer 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 RemoteTrustChainsContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(immediate));
+        final RemoteEntityConfigurationContainer immediateContainer =
+                ecContainer(immediate, CollectionSupport.listOf(anchor));
+        final RemoteEntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(immediateContainer),
+                    CollectionSupport.listOf(authorityContainer), CollectionSupport.emptyList());
+        final RemoteSubordinateStatementContainer ssContainer1 = ssContainer(leaf, immediate);
+        final RemoteSubordinateStatementContainer 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 RemoteTrustChainsContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final RemoteEntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final RemoteSubordinateStatementContainer ssContainer = ssContainer(leaf, anchor);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer),
+                CollectionSupport.emptyList());
+        final RemoteTrustChainsContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor, "https://federation.local/other"));
+        final RemoteEntityConfigurationContainer authorityContainer =
+                ecContainer(anchor, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer),
+                    CollectionSupport.emptyList());
+        final RemoteSubordinateStatementContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor1, anchor2));
+        final RemoteEntityConfigurationContainer authorityContainer1 =
+                ecContainer(anchor1, CollectionSupport.emptyList());
+        final RemoteEntityConfigurationContainer authorityContainer2 =
+                ecContainer(anchor2, CollectionSupport.emptyList());
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer1),
+                    CollectionSupport.listOf(authorityContainer2));
+        final RemoteSubordinateStatementContainer ssContainer1 = ssContainer(leaf, anchor1);
+        final RemoteSubordinateStatementContainer ssContainer2 = ssContainer(leaf, anchor2);
+        when(subordinateStatementCache.get(any())).thenReturn(CollectionSupport.listOf(ssContainer1),
+                CollectionSupport.listOf(ssContainer2));
+        final RemoteTrustChainsContainer 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 {
+        setup(new HashMap<>());
+        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 RemoteEntityConfigurationContainer ecContainer =
+                ecContainer(leaf, CollectionSupport.listOf(anchor1));
+        final RemoteEntityConfigurationContainer authorityContainer1 =
+                ecContainer(anchor1, CollectionSupport.listOf(anchor2));
+        final RemoteEntityConfigurationContainer authorityContainer2 =
+                ecContainer(anchor2, CollectionSupport.listOf(anchor1));
+        when(entityConfigurationCache.get(any()))
+            .thenReturn(CollectionSupport.listOf(ecContainer), CollectionSupport.listOf(authorityContainer1),
+                    CollectionSupport.listOf(authorityContainer2));
+        final RemoteSubordinateStatementContainer ssContainer1 = ssContainer(leaf, anchor1);
+        final RemoteSubordinateStatementContainer ssContainer2 = ssContainer(anchor1, anchor2);
+        final RemoteSubordinateStatementContainer 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 RemoteTrustChainsContainer container) {
+        Assert.assertNotNull(container);
+        Assert.assertEquals(container.getTrustChains(), CollectionSupport.emptyList());
+    }
+    
+    protected RemoteEntityConfigurationContainer 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 RemoteEntityConfigurationContainer(id, configuration, expiration, expiration);
+    }
+
+    protected RemoteSubordinateStatementContainer 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 RemoteSubordinateStatementContainer(
+                new SubordinateStatementCacheIdentifier(issuer, subject), statement, expiration, expiration);
+    }
+}
diff --git a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java
index 5cb4e4f..b56da5c 100644
--- a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java
+++ b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/AbstractFederationFlowTest.java
@@ -95,6 +95,7 @@ public class AbstractFederationFlowTest extends AbstractOidcFlowTest {
     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";
@@ -107,6 +108,7 @@ public class AbstractFederationFlowTest extends AbstractOidcFlowTest {
     protected JWK anchorKey;
     protected JWK trustedAnchorKey;
     protected JWK intermediateKey;
+    protected JWK trustedIntermediateKey;
     protected JWK trustMarkIssuerKey;
 
     protected String subject = "jdoe";
@@ -129,6 +131,11 @@ public class AbstractFederationFlowTest extends AbstractOidcFlowTest {
                 .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");
     }
@@ -362,17 +369,21 @@ public class AbstractFederationFlowTest extends AbstractOidcFlowTest {
     }
 
     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", new JWKSet(intermediateKey).toJSONObject(true))
+                .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<?> anchorConfiguration =
-                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, intermediateKey, claimsSet);
-        return anchorConfiguration.getJwt().serialize();
+        final EntityStatement<?> intermediateConfiguration =
+                TrustChainTestUtil.entityStatement(JWSAlgorithm.RS256, signerKey, claimsSet);
+        return intermediateConfiguration.getJwt().serialize();
     }
 
     protected String subordinateStatement(final String issuer, final Map<String, Object> metadata) {
diff --git a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java
index fae03f6..18e8fda 100644
--- a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java
+++ b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/EntityConfigurationFlowTest.java
@@ -161,7 +161,7 @@ public class EntityConfigurationFlowTest extends AbstractFederationFlowTest {
         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));
+        Assert.assertEquals(payload.getTrustAnchorHints(), List.of(anchorId, trustedIntermediateId));
         final List<Map<String,String>> trustMarkMap = payload.getTrustMarks();
         Assert.assertNotNull(trustMarkMap);
         assert trustMarkMap != null;
diff --git a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java
index 35d2bb7..432e0c8 100644
--- a/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java
+++ b/idp-oidfed-op-impl/src/test/java/net/shibboleth/idp/plugin/oidc/op/profile/flow/oidfed/cache/TrustChainMetadataCacheTest.java
@@ -185,4 +185,50 @@ public class TrustChainMetadataCacheTest extends AbstractFederationFlowTest {
             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<RemoteTrustChainsContainer> 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);
+        }
+    }
+
 }
diff --git a/idp-oidfed-op-impl/src/test/resources/credentials/fed-local-intermediate.jwk b/idp-oidfed-op-impl/src/test/resources/credentials/fed-local-intermediate.jwk
new file mode 100644
index 0000000..7ba5321
--- /dev/null
+++ b/idp-oidfed-op-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/idp-oidfed-op-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json b/idp-oidfed-op-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json
index 5464750..ff5a6b9 100644
--- a/idp-oidfed-op-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json
+++ b/idp-oidfed-op-impl/src/test/resources/net/shibboleth/idp/module/conf/oidfed/oidfed-trust-anchors.json
@@ -9,5 +9,16 @@
                 "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"
+            }
+        ]
     }
 }

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


More information about the commits mailing list