From 0e93c929a81382282e4c39ab2c556e50525defb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 17 Apr 2026 12:28:50 +0200 Subject: [PATCH 01/16] Update specs --- specifications/update-specs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifications/update-specs.sh b/specifications/update-specs.sh index 5c8f0c8..5539c4a 100755 --- a/specifications/update-specs.sh +++ b/specifications/update-specs.sh @@ -13,7 +13,7 @@ URLS=( # OpenID specifications "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html" "https://openid.net/specs/openid-federation-1_0.html" - #"https://zachmann.github.io/openid-federation-entity-collection/main.html" + #"https://openid.github.io/federation-entity-collection/main.html" "https://openid.net/specs/openid-connect-core-1_0.html" "https://openid.net/specs/openid-connect-discovery-1_0.html" "https://openid.net/specs/openid-connect-rpinitiated-1_0.html" From dcdf2f037d371085783e25389eb912e086a2723b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 22 Apr 2026 17:34:39 +0200 Subject: [PATCH 02/16] WIP --- src/Codebooks/ClaimsEnum.php | 4 + src/Exceptions/EntityDiscoveryException.php | 9 + src/Federation.php | 103 ++++++++++ src/Federation/CacheEntityCollectionStore.php | 64 +++++++ src/Federation/EntityCollection.php | 16 ++ src/Federation/EntityCollectionBuilder.php | 116 +++++++++++ src/Federation/EntityCollectionEntry.php | 52 +++++ src/Federation/EntityCollectionFetcher.php | 94 +++++++++ src/Federation/EntityCollectionFilter.php | 129 +++++++++++++ src/Federation/EntityCollectionPaginator.php | 44 +++++ src/Federation/EntityCollectionResponse.php | 43 +++++ src/Federation/EntityCollectionSorter.php | 52 +++++ .../EntityCollectionStoreInterface.php | 33 ++++ src/Federation/EntityStatement.php | 50 +++++ src/Federation/FederationDiscovery.php | 180 ++++++++++++++++++ .../InMemoryEntityCollectionStore.php | 41 ++++ src/Federation/SubordinateListingFetcher.php | 61 ++++++ src/Helpers/Type.php | 13 ++ src/Helpers/Url.php | 56 ++++++ .../VcDataModel2/Factories/VcSdJwtFactory.php | 14 -- tests/src/Helpers/UrlTest.php | 27 +++ .../Factories/VcSdJwtFactoryTest.php | 17 -- 22 files changed, 1187 insertions(+), 31 deletions(-) create mode 100644 src/Exceptions/EntityDiscoveryException.php create mode 100644 src/Federation/CacheEntityCollectionStore.php create mode 100644 src/Federation/EntityCollection.php create mode 100644 src/Federation/EntityCollectionBuilder.php create mode 100644 src/Federation/EntityCollectionEntry.php create mode 100644 src/Federation/EntityCollectionFetcher.php create mode 100644 src/Federation/EntityCollectionFilter.php create mode 100644 src/Federation/EntityCollectionPaginator.php create mode 100644 src/Federation/EntityCollectionResponse.php create mode 100644 src/Federation/EntityCollectionSorter.php create mode 100644 src/Federation/EntityCollectionStoreInterface.php create mode 100644 src/Federation/FederationDiscovery.php create mode 100644 src/Federation/InMemoryEntityCollectionStore.php create mode 100644 src/Federation/SubordinateListingFetcher.php diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index b99c6f1..9ee1c05 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -173,6 +173,10 @@ enum ClaimsEnum: string case Expiration_Date = 'expirationDate'; + case EntityTypes = 'entity_types'; + + case FederationCollectionEndpoint = 'federation_collection_endpoint'; + case FederationFetchEndpoint = 'federation_fetch_endpoint'; case FederationListEndpoint = 'federation_list_endpoint'; diff --git a/src/Exceptions/EntityDiscoveryException.php b/src/Exceptions/EntityDiscoveryException.php new file mode 100644 index 0000000..786d95d --- /dev/null +++ b/src/Exceptions/EntityDiscoveryException.php @@ -0,0 +1,9 @@ +maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration); $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() ->build($timestampValidationLeeway); $this->maxTrustChainDepth = min(20, max(1, $maxTrustChainDepth)); + $this->maxDiscoveryDepth = max(1, $maxDiscoveryDepth); $this->cacheDecorator = is_null($cache) ? null : $this->cacheDecoratorFactory()->build($cache); $this->httpClientDecorator = $this->httpClientDecoratorFactory()->build($client); } @@ -321,6 +350,80 @@ public function trustMarkFetcher(): TrustMarkFetcher } + public function subordinateListingFetcher(): SubordinateListingFetcher + { + return $this->subordinateListingFetcher ??= new SubordinateListingFetcher( + $this->artifactFetcher(), + $this->helpers(), + $this->logger, + ); + } + + + public function federationDiscovery(?EntityCollectionStoreInterface $store = null): FederationDiscovery + { + if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) { + $effectiveStore = $store ?? ($this->cacheDecorator() instanceof \SimpleSAML\OpenID\Decorators\CacheDecorator + ? new CacheEntityCollectionStore($this->cacheDecorator()) + : new InMemoryEntityCollectionStore()); + + $this->federationDiscovery = new FederationDiscovery( + $this->entityStatementFetcher(), + $this->subordinateListingFetcher(), + $effectiveStore, + $this->maxCacheDurationDecorator(), + $this->logger, + $this->maxDiscoveryDepth, + ); + } + + return $this->federationDiscovery; + } + + + public function entityCollectionFetcher(): EntityCollectionFetcher + { + return $this->entityCollectionFetcher ??= new EntityCollectionFetcher( + $this->artifactFetcher(), + $this->helpers(), + $this->logger, + ); + } + + + public function entityCollectionFilter(): EntityCollectionFilter + { + return $this->entityCollectionFilter ??= new EntityCollectionFilter($this->helpers()); + } + + + public function entityCollectionSorter(): EntityCollectionSorter + { + return $this->entityCollectionSorter ??= new EntityCollectionSorter($this->helpers()); + } + + + public function entityCollectionPaginator(): EntityCollectionPaginator + { + return $this->entityCollectionPaginator ??= new EntityCollectionPaginator(); + } + + + /** + * @param \SimpleSAML\OpenID\Federation\EntityCollectionStoreInterface|null $store Forwarded to + * federationDiscovery() + */ + public function entityCollectionBuilder(?EntityCollectionStoreInterface $store = null): EntityCollectionBuilder + { + return $this->entityCollectionBuilder ??= new EntityCollectionBuilder( + $this->federationDiscovery($store), + $this->entityCollectionFilter(), + $this->entityCollectionSorter(), + $this->entityCollectionPaginator(), + ); + } + + public function helpers(): Helpers { return $this->helpers ??= new Helpers(); diff --git a/src/Federation/CacheEntityCollectionStore.php b/src/Federation/CacheEntityCollectionStore.php new file mode 100644 index 0000000..dac93ea --- /dev/null +++ b/src/Federation/CacheEntityCollectionStore.php @@ -0,0 +1,64 @@ +cacheDecorator->set( + json_encode($entityIds, JSON_THROW_ON_ERROR), + $ttl, + self::PREFIX, + $trustAnchorId, + ); + } catch (Throwable) { + // Log if needed, or ignore for now as per ArtifactFetcher pattern + } + } + + + public function getEntityIds(string $trustAnchorId): ?array + { + try { + /** @var ?string $cached */ + $cached = $this->cacheDecorator->get(null, self::PREFIX, $trustAnchorId); + + if (is_null($cached)) { + return null; + } + + /** @var non-empty-string[] $decoded */ + $decoded = json_decode($cached, true, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } catch (Throwable) { + return null; + } + } + + + public function clearEntityIds(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::PREFIX, $trustAnchorId); + } catch (Throwable) { + // Ignore + } + } +} diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php new file mode 100644 index 0000000..41d690f --- /dev/null +++ b/src/Federation/EntityCollection.php @@ -0,0 +1,16 @@ + $entities Keyed by entity ID + */ + public function __construct( + public readonly array $entities, + ) { + } +} diff --git a/src/Federation/EntityCollectionBuilder.php b/src/Federation/EntityCollectionBuilder.php new file mode 100644 index 0000000..62a35fd --- /dev/null +++ b/src/Federation/EntityCollectionBuilder.php @@ -0,0 +1,116 @@ +federationDiscovery->discoverAndFetch($trustAnchorId); + $collection = new EntityCollection($entities); + + // 2. Filter + $filtered = $this->filter->filter($collection, $requestParams); + + // 3. Sort + if (isset($requestParams['sort_by'])) { + $path = explode('.', $requestParams['sort_by']); + /** @var non-empty-string[] $path */ + $filtered = $this->sorter->sortByMetadataClaim( + $filtered, + $path, + (string)($requestParams['sort_dir'] ?? 'asc'), + ); + } + + // 4. Claims sub-selection (Projection) + $entries = []; + $uiClaims = $requestParams['ui_claims'] ?? null; + + foreach ($filtered as $id => $statement) { + $metadata = $statement->getMetadata() ?? []; + /** @var non-empty-string[] $entityTypes */ + $entityTypes = array_keys($metadata); + + // ui_info projection + $uiInfo = null; + if (is_array($uiClaims) && $uiClaims !== []) { + $uiInfo = []; + foreach ($metadata as $payload) { + if (!is_array($payload)) { + continue; + } + + foreach ($uiClaims as $claim) { + if (isset($payload[$claim])) { + $uiInfo[$claim] = $payload[$claim]; + } + } + } + } + + // trust_marks projection is handled by getting them from statement + $trustMarks = null; + try { + // In a real projection, we might filter which trust marks to return, + // but for now we return all if asked or if no specific selection is implemented. + $trustMarks = $statement->getTrustMarks(); + } catch (\Throwable) { + } + + // If entity_claims is provided, we might want to filter the metadata itself, + // but the EntityCollectionEntry DTO currently separates ui_info. + // For now, project into the Entry VO. + /** @var non-empty-string $id */ + $entries[$id] = new EntityCollectionEntry( + $id, + $entityTypes, + $uiInfo, + $trustMarks?->jsonSerialize(), + ); + } + + // 5. Paginate + $limit = isset($requestParams['limit']) ? (int)$requestParams['limit'] : 100; + $limit = max(1, $limit); + + $from = $requestParams['from'] ?? null; + + $paginated = $this->paginator->paginate($entries, $limit, $from); + + return new EntityCollectionResponse( + array_values($paginated['entities']), + $paginated['next'], + time(), // last_updated + ); + } +} diff --git a/src/Federation/EntityCollectionEntry.php b/src/Federation/EntityCollectionEntry.php new file mode 100644 index 0000000..7d4fe64 --- /dev/null +++ b/src/Federation/EntityCollectionEntry.php @@ -0,0 +1,52 @@ +|null $uiInfo Logo, display name, etc. + * @param array>|null $trustMarks + */ + public function __construct( + public readonly string $entityId, + public readonly array $entityTypes, + public readonly ?array $uiInfo = null, + public readonly ?array $trustMarks = null, + ) { + } + + + /** + * @return array{ + * entity_id: non-empty-string, + * entity_types: non-empty-string[], + * ui_info?: array, + * trust_marks?: array> + * } + */ + public function jsonSerialize(): array + { + $data = [ + 'entity_id' => $this->entityId, + ClaimsEnum::EntityTypes->value => $this->entityTypes, + ]; + + if (!is_null($this->uiInfo)) { + $data['ui_info'] = $this->uiInfo; + } + + if (!is_null($this->trustMarks)) { + $data[ClaimsEnum::TrustMarks->value] = $this->trustMarks; + } + + return $data; + } +} diff --git a/src/Federation/EntityCollectionFetcher.php b/src/Federation/EntityCollectionFetcher.php new file mode 100644 index 0000000..21da886 --- /dev/null +++ b/src/Federation/EntityCollectionFetcher.php @@ -0,0 +1,94 @@ +helpers->url()->withMultiValueParams($endpointUri, $filters); + + $this->logger?->debug('Fetching entity collection.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + + /** @var mixed $decoded */ + $decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($decoded) || !isset($decoded['entities']) || !is_array($decoded['entities'])) { + throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); + } + + $entries = []; + foreach ($decoded['entities'] as $entryData) { + if (!is_array($entryData)) { + continue; + } + + /** @var array|null $uiInfo */ + $uiInfo = is_array($entryData['ui_info'] ?? null) ? $entryData['ui_info'] : null; + /** @var array>|null $trustMarks */ + $trustMarks = is_array($entryData[ClaimsEnum::TrustMarks->value] ?? null) + ? $entryData[ClaimsEnum::TrustMarks->value] + : null; + + $entries[] = new EntityCollectionEntry( + $this->helpers->type()->ensureNonEmptyString($entryData[ClaimsEnum::Id->value] ?? null), + $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings( + $entryData[ClaimsEnum::EntityTypes->value] ?? [], + 'entity_types', + ), + $uiInfo, + $trustMarks, + ); + } + + $lastUpdated = $decoded['last_updated'] ?? null; + + return new EntityCollectionResponse( + $entries, + $this->helpers->type()->getNonEmptyStringOrNull($decoded['next'] ?? null), + is_numeric($lastUpdated) ? (int)$lastUpdated : null, + ); + } catch (Throwable $throwable) { + $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } +} diff --git a/src/Federation/EntityCollectionFilter.php b/src/Federation/EntityCollectionFilter.php new file mode 100644 index 0000000..e09bad5 --- /dev/null +++ b/src/Federation/EntityCollectionFilter.php @@ -0,0 +1,129 @@ + Filtered + * entity configurations keyed by entity ID + */ + public function filter(EntityCollection $collection, array $criteria): array + { + $filtered = $collection->entities; + + // 1. entity_type + if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { + $types = $criteria['entity_type']; + $filtered = array_filter($filtered, function (EntityStatement $statement) use ($types): bool { + $metadata = $statement->getMetadata(); + foreach ($types as $type) { + if (isset($metadata[$type])) { + return true; + } + } + + return false; + }); + } + + // 2. trust_mark_type + if (isset($criteria['trust_mark_type'])) { + $tmType = $criteria['trust_mark_type']; + $filtered = array_filter($filtered, function (EntityStatement $statement) use ($tmType): bool { + try { + $marks = $statement->getTrustMarks(); + if ($marks instanceof \SimpleSAML\OpenID\Federation\Claims\TrustMarksClaimBag) { + foreach ($marks->getAll() as $mark) { + if ($mark->getTrustMarkType() === $tmType) { + return true; + } + } + } + } catch (\Throwable) { + return false; + } + + return false; + }); + } + + // 3. query + if (isset($criteria['query']) && $criteria['query'] !== '') { + $q = mb_strtolower($criteria['query']); + $filtered = array_filter($filtered, function (EntityStatement $statement) use ($q): bool { + $sub = mb_strtolower($statement->getSubject()); + if (str_contains($sub, $q)) { + return true; + } + + $metadata = $statement->getMetadata(); + if ($metadata === null) { + return false; + } + + // Check display_name or organization_name in any entity type + foreach ($metadata as $typePayload) { + if (!is_array($typePayload)) { + continue; + } + + $displayNameValue = $typePayload['display_name'] ?? ''; + $displayName = mb_strtolower(is_string($displayNameValue) ? $displayNameValue : ''); + if ($displayName !== '' && str_contains($displayName, $q)) { + return true; + } + + $orgNameValue = $typePayload['organization_name'] ?? ''; + $orgName = mb_strtolower(is_string($orgNameValue) ? $orgNameValue : ''); + if ($orgName !== '' && str_contains($orgName, $q)) { + return true; + } + } + + return false; + }); + } + + // 4. trust_anchor (simple prefix match for now as per spec suggestion, + // or more complex if needed). Historically, in some federation + // implementations, subordination is indicated via id prefix or + // specific claims. For this building block, we'll implement it as a + // filter on the authority hint if possible. + if (isset($criteria['trust_anchor'])) { + $ta = $criteria['trust_anchor']; + $filtered = array_filter($filtered, function (EntityStatement $statement) use ($ta): bool { + // In a top-down traversal, everything is subordinate to the TA we started with. + // If the collection contains multiple TAs, we would check authority_hints. + $hints = $this->helpers->arr()->getNestedValue( + $statement->getPayload(), + ClaimsEnum::AuthorityHints->value, + ); + if (is_array($hints)) { + return in_array($ta, $hints, true); + } + + return false; + }); + } + + return $filtered; + } +} diff --git a/src/Federation/EntityCollectionPaginator.php b/src/Federation/EntityCollectionPaginator.php new file mode 100644 index 0000000..72b6aa6 --- /dev/null +++ b/src/Federation/EntityCollectionPaginator.php @@ -0,0 +1,44 @@ + $entities Full ordered result set (pre-sorted) + * @param positive-int $limit Maximum number of entries to return + * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) + * @return array{entities: array, next: ?string} + */ + public function paginate(array $entities, int $limit, ?string $from = null): array + { + $keys = array_keys($entities); + $offset = 0; + + if (!is_null($from)) { + $fromId = base64_decode($from, true); + if ($fromId !== false) { + $index = array_search($fromId, $keys, true); + if ($index !== false) { + $offset = $index + 1; + } + } + } + + $pageItems = array_slice($entities, $offset, $limit, true); + $next = null; + + if ($offset + $limit < count($keys)) { + $lastIdInPage = array_key_last($pageItems); + $next = base64_encode((string)$lastIdInPage); + } + + return [ + 'entities' => $pageItems, + 'next' => $next, + ]; + } +} diff --git a/src/Federation/EntityCollectionResponse.php b/src/Federation/EntityCollectionResponse.php new file mode 100644 index 0000000..f291627 --- /dev/null +++ b/src/Federation/EntityCollectionResponse.php @@ -0,0 +1,43 @@ + $this->entities, + ]; + + if (!is_null($this->next)) { + $data['next'] = $this->next; + } + + if (!is_null($this->lastUpdated)) { + $data['last_updated'] = $this->lastUpdated; + } + + return $data; + } +} diff --git a/src/Federation/EntityCollectionSorter.php b/src/Federation/EntityCollectionSorter.php new file mode 100644 index 0000000..7f75dcc --- /dev/null +++ b/src/Federation/EntityCollectionSorter.php @@ -0,0 +1,52 @@ + $entities Keyed by entity ID + * @param non-empty-string[] $claimPath Nested claim path within the metadata + * object (e.g. ['federation_entity', 'display_name']) + * @param 'asc'|'desc' $direction + * @return array Sorted copy + */ + public function sortByMetadataClaim( + array $entities, + array $claimPath, + string $direction = 'asc', + ): array { + if ($entities === []) { + return []; + } + + uasort($entities, function (EntityStatement $a, EntityStatement $b) use ($claimPath, $direction): int { + $metadataA = $a->getMetadata() ?? []; + $metadataB = $b->getMetadata() ?? []; + $valA = $this->helpers->arr()->getNestedValue($metadataA, ...$claimPath); + $valB = $this->helpers->arr()->getNestedValue($metadataB, ...$claimPath); + + // Treat nulls or non-strings as empty strings for comparison + $strA = is_string($valA) ? $valA : ''; + $strB = is_string($valB) ? $valB : ''; + + $cmp = strcasecmp($strA, $strB); + + return $direction === 'desc' ? -$cmp : $cmp; + }); + + return $entities; + } +} diff --git a/src/Federation/EntityCollectionStoreInterface.php b/src/Federation/EntityCollectionStoreInterface.php new file mode 100644 index 0000000..b2cb11f --- /dev/null +++ b/src/Federation/EntityCollectionStoreInterface.php @@ -0,0 +1,33 @@ +helpers->arr()->getNestedValue( + $this->getPayload(), + ClaimsEnum::Metadata->value, + EntityTypesEnum::FederationEntity->value, + ClaimsEnum::FederationListEndpoint->value, + ); + + if (is_null($federationListEndpoint)) { + return null; + } + + return $this->helpers->type()->ensureNonEmptyString($federationListEndpoint); + } + + + /** + * @return ?non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function getFederationCollectionEndpoint(): ?string + { + $federationCollectionEndpoint = $this->helpers->arr()->getNestedValue( + $this->getPayload(), + ClaimsEnum::Metadata->value, + EntityTypesEnum::FederationEntity->value, + ClaimsEnum::FederationCollectionEndpoint->value, + ); + + if (is_null($federationCollectionEndpoint)) { + return null; + } + + return $this->helpers->type()->ensureNonEmptyString($federationCollectionEndpoint); + } + + /** * @return non-empty-string * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -449,6 +497,8 @@ protected function validate(): void $this->getTrustMarkOwners(...), $this->getTrustMarkIssuers(...), $this->getFederationFetchEndpoint(...), + $this->getFederationListEndpoint(...), + $this->getFederationCollectionEndpoint(...), $this->getFederationTrustMarkEndpoint(...), $this->getFederationTrustMarkStatusEndpoint(...), ); diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php new file mode 100644 index 0000000..ca4a701 --- /dev/null +++ b/src/Federation/FederationDiscovery.php @@ -0,0 +1,180 @@ + $filters Passed through to + * SubordinateListingFetcher + * @param bool $forceRefresh If true, ignore stored entity IDs and + * re-traverse the federation + * @return non-empty-string[] + */ + public function discoverEntities( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): array { + if ($forceRefresh) { + $this->store->clearEntityIds($trustAnchorId); + } + + $cachedIds = $this->store->getEntityIds($trustAnchorId); + if (is_array($cachedIds)) { + $this->logger?->debug('Returning discovered entity IDs from store.', ['trustAnchorId' => $trustAnchorId]); + return $cachedIds; + } + + $this->logger?->info( + 'Starting federation discovery.', + ['trustAnchorId' => $trustAnchorId, 'filters' => $filters], + ); + + $discoveredIds = []; + try { + // Step 1: Fetch TA config + $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); + + // Recursive traversal + $discoveredIds = $this->traverse($trustAnchorId, $taConfig, $filters); + $discoveredIds = array_unique($discoveredIds); + + // Compute TTL: lowest of maxCacheDuration and TA expiry + $ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime( + $taConfig->getExpirationTime(), + ); + + $this->store->storeEntityIds($trustAnchorId, $discoveredIds, $ttl); + $this->logger?->info('Federation discovery completed.', [ + 'trustAnchorId' => $trustAnchorId, + 'discoveredCount' => count($discoveredIds), + ]); + } catch (Throwable $throwable) { + $this->logger?->error('Federation discovery failed.', [ + 'trustAnchorId' => $trustAnchorId, + 'error' => $throwable->getMessage(), + ]); + } + + return $discoveredIds; + } + + + /** + * @param non-empty-string $entityId + * @param array $filters + * @param string[] $visited + * @return non-empty-string[] + */ + private function traverse( + string $entityId, + EntityStatement $entityConfig, + array $filters, + int $depth = 0, + array $visited = [], + ): array { + if ($depth > $this->maxDepth || in_array($entityId, $visited, true)) { + return []; + } + + $visited[] = $entityId; + $allCollectedIds = [$entityId]; + + $listEndpoint = $entityConfig->getFederationListEndpoint(); + if (is_null($listEndpoint)) { + return $allCollectedIds; + } + + try { + $subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters); + + foreach ($subordinateIds as $subId) { + try { + $subConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($subId); + $allCollectedIds = array_merge( + $allCollectedIds, + $this->traverse($subId, $subConfig, $filters, $depth + 1, $visited), + ); + } catch (Throwable $e) { + $this->logger?->warning('Failed to fetch subordinate configuration during discovery.', [ + 'entityId' => $entityId, + 'subId' => $subId, + 'error' => $e->getMessage(), + ]); + // Still include the ID if we discovered it from the list + $allCollectedIds[] = $subId; + } + } + } catch (Throwable $throwable) { + $this->logger?->error('Failed to fetch subordinate listing during discovery.', [ + 'entityId' => $entityId, + 'error' => $throwable->getMessage(), + ]); + } + + return $allCollectedIds; + } + + + /** + * Return Entity Configurations for the given entity IDs, fetched from cache or network. + * + * @param non-empty-string[] $entityIds + * @return array keyed by entity ID + */ + public function fetchEntityConfigurations(array $entityIds): array + { + $entities = []; + foreach ($entityIds as $id) { + try { + $entities[$id] = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($id); + } catch (Throwable $e) { + $this->logger?->warning('Failed to fetch entity configuration.', [ + 'entityId' => $id, + 'error' => $e->getMessage(), + ]); + } + } + + return $entities; + } + + + /** + * Convenience: discover entity IDs then fetch their Entity Configurations. + * + * @param non-empty-string $trustAnchorId + * @param array $filters + * @return array + */ + public function discoverAndFetch( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): array { + $ids = $this->discoverEntities($trustAnchorId, $filters, $forceRefresh); + return $this->fetchEntityConfigurations($ids); + } +} diff --git a/src/Federation/InMemoryEntityCollectionStore.php b/src/Federation/InMemoryEntityCollectionStore.php new file mode 100644 index 0000000..90f53e5 --- /dev/null +++ b/src/Federation/InMemoryEntityCollectionStore.php @@ -0,0 +1,41 @@ + */ + private array $store = []; + + + public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void + { + $this->store[$trustAnchorId] = [ + 'ids' => $entityIds, + 'expires' => time() + $ttl, + ]; + } + + + public function getEntityIds(string $trustAnchorId): ?array + { + if (!isset($this->store[$trustAnchorId])) { + return null; + } + + if ($this->store[$trustAnchorId]['expires'] < time()) { + unset($this->store[$trustAnchorId]); + return null; + } + + return $this->store[$trustAnchorId]['ids']; + } + + + public function clearEntityIds(string $trustAnchorId): void + { + unset($this->store[$trustAnchorId]); + } +} diff --git a/src/Federation/SubordinateListingFetcher.php b/src/Federation/SubordinateListingFetcher.php new file mode 100644 index 0000000..eb2c65f --- /dev/null +++ b/src/Federation/SubordinateListingFetcher.php @@ -0,0 +1,61 @@ + $filters Optional query params: entity_type, intermediate, etc. + * @return non-empty-string[] + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + public function fetch(string $listEndpointUri, array $filters = []): array + { + $uri = $this->helpers->url()->withMultiValueParams($listEndpointUri, $filters); + + $this->logger?->debug('Fetching subordinate listing.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + $this->logger?->debug('Fetched subordinate listing from network.', ['uri' => $uri]); + + /** @var mixed $decoded */ + $decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($decoded)) { + throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); + } + + return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, ClaimsEnum::Sub->value); + } catch (Throwable $throwable) { + $message = sprintf( + 'Unable to fetch subordinate listing from %s. Error: %s', + $uri, + $throwable->getMessage(), + ); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } +} diff --git a/src/Helpers/Type.php b/src/Helpers/Type.php index fa281ec..64ef407 100644 --- a/src/Helpers/Type.php +++ b/src/Helpers/Type.php @@ -57,6 +57,19 @@ public function ensureNonEmptyString(mixed $value, ?string $context = null): str } + /** + * @return non-empty-string|null + */ + public function getNonEmptyStringOrNull(mixed $value): ?string + { + if (is_string($value) && $value !== '') { + return $value; + } + + return null; + } + + /** * @return mixed[] * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index 0f6e8e5..aaa1a12 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -40,4 +40,60 @@ public function withParams(string $url, array $params): string '?' . $newQueryString . (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); } + + + /** + * Build a URL with repeated (multi-value) query parameters. + * Array values are serialized as repeated keys: ?key=a&key=b + * + * @param array|string|int|float> $params + */ + public function withMultiValueParams(string $url, array $params): string + { + if ($params === []) { + return $url; + } + + $parsedUri = parse_url($url); + + $queryParams = []; + if (isset($parsedUri['query'])) { + parse_str($parsedUri['query'], $queryParams); + } + + $queryElements = []; + // Preserve existing query params + foreach ($queryParams as $key => $value) { + $strKey = (string)$key; + if (is_array($value)) { + foreach ($value as $subValue) { + /** @var string $subValue */ + $queryElements[] = urlencode($strKey) . '=' . urlencode($subValue); + } + } else { + /** @var string $value */ + $queryElements[] = urlencode($strKey) . '=' . urlencode($value); + } + } + + // Add new multi-value params + foreach ($params as $key => $value) { + if (is_array($value)) { + foreach ($value as $subValue) { + $queryElements[] = urlencode($key) . '=' . urlencode((string)$subValue); + } + } else { + $queryElements[] = urlencode($key) . '=' . urlencode((string)$value); + } + } + + $newQueryString = implode('&', $queryElements); + + return (isset($parsedUri['scheme']) ? $parsedUri['scheme'] . '://' : '') . + ($parsedUri['host'] ?? '') . + (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . + ($parsedUri['path'] ?? '') . + '?' . $newQueryString . + (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); + } } diff --git a/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php b/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php index fd24bf9..54163e5 100644 --- a/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php +++ b/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactory.php @@ -16,20 +16,6 @@ class VcSdJwtFactory extends SdJwtFactory { - public function fromToken(string $token): VcSdJwt - { - return new VcSdJwt( - $this->jwsDecoratorBuilder->fromToken($token), - $this->jwsVerifierDecorator, - $this->jwksDecoratorFactory, - $this->jwsSerializerManagerDecorator, - $this->timestampValidationLeeway, - $this->helpers, - $this->claimFactory, - ); - } - - /** * @param array $payload * @param array $header diff --git a/tests/src/Helpers/UrlTest.php b/tests/src/Helpers/UrlTest.php index cc02f35..a842d75 100644 --- a/tests/src/Helpers/UrlTest.php +++ b/tests/src/Helpers/UrlTest.php @@ -43,4 +43,31 @@ public function testCanAddParams(): void $this->sut()->withParams($url, ['c' => 'd']), ); } + + + public function testCanAddMultiValueParams(): void + { + $url = 'https://example.com/'; + + $this->assertSame( + 'https://example.com/', + $this->sut()->withMultiValueParams($url, []), + ); + + $this->assertSame( + 'https://example.com/?a=b&a=c', + $this->sut()->withMultiValueParams($url, ['a' => ['b', 'c']]), + ); + + $this->assertSame( + 'https://example.com/?a=b&c=d', + $this->sut()->withMultiValueParams($url, ['a' => 'b', 'c' => 'd']), + ); + + $url = 'https://example.com/?x=y'; + $this->assertSame( + 'https://example.com/?x=y&a=b&a=c', + $this->sut()->withMultiValueParams($url, ['a' => ['b', 'c']]), + ); + } } diff --git a/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php b/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php index 2f2c9b5..056eaf4 100644 --- a/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php +++ b/tests/src/VerifiableCredentials/VcDataModel2/Factories/VcSdJwtFactoryTest.php @@ -102,23 +102,6 @@ protected function createJwsDecoratorMock(array $payload = []): MockObject } - public function testCanBuildFromToken(): void - { - $jwsDecoratorMock = $this->createJwsDecoratorMock(); - - $this->jwsDecoratorBuilderMock - ->expects($this->once()) - ->method('fromToken') - ->with('token') - ->willReturn($jwsDecoratorMock); - - $this->assertInstanceOf( - VcSdJwt::class, - $this->sut()->fromToken('token'), - ); - } - - public function testCanBuildFromData(): void { $signingKey = $this->createStub(JwkDecorator::class); From 8c1cd8baa54db5b6f3935b2f6840c9cdeed75351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 23 Apr 2026 12:44:05 +0200 Subject: [PATCH 03/16] WIP --- src/Codebooks/ClaimsEnum.php | 11 +++ src/Federation.php | 59 ++++++++------ src/Federation/CacheEntityCollectionStore.php | 64 --------------- src/Federation/EntityCollection.php | 11 ++- .../CacheEntityCollectionStore.php | 77 +++++++++++++++++++ .../EntityCollectionEntry.php | 18 ++--- .../EntityCollectionFetcher.php | 34 ++++---- .../EntityCollectionFilter.php | 14 ++-- .../EntityCollectionPaginator.php | 29 ++++--- .../EntityCollectionResponse.php | 13 ++-- .../EntityCollectionResponseFactory.php} | 15 ++-- .../EntityCollectionSorter.php | 5 +- .../EntityCollectionStoreInterface.php | 2 +- .../InMemoryEntityCollectionStore.php | 2 +- src/Federation/FederationDiscovery.php | 19 ++--- src/Federation/SubordinateListingFetcher.php | 9 +-- src/Helpers/Type.php | 13 ---- 17 files changed, 225 insertions(+), 170 deletions(-) delete mode 100644 src/Federation/CacheEntityCollectionStore.php create mode 100644 src/Federation/EntityCollection/CacheEntityCollectionStore.php rename src/Federation/{ => EntityCollection}/EntityCollectionEntry.php (65%) rename src/Federation/{ => EntityCollection}/EntityCollectionFetcher.php (70%) rename src/Federation/{ => EntityCollection}/EntityCollectionFilter.php (89%) rename src/Federation/{ => EntityCollection}/EntityCollectionPaginator.php (56%) rename src/Federation/{ => EntityCollection}/EntityCollectionResponse.php (61%) rename src/Federation/{EntityCollectionBuilder.php => EntityCollection/EntityCollectionResponseFactory.php} (88%) rename src/Federation/{ => EntityCollection}/EntityCollectionSorter.php (90%) rename src/Federation/{ => EntityCollection}/EntityCollectionStoreInterface.php (93%) rename src/Federation/{ => EntityCollection}/InMemoryEntityCollectionStore.php (94%) diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index 9ee1c05..1d4a581 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -173,6 +173,10 @@ enum ClaimsEnum: string case Expiration_Date = 'expirationDate'; + case Entities = 'entities'; + + case EntityId = 'entity_id'; + case EntityTypes = 'entity_types'; case FederationCollectionEndpoint = 'federation_collection_endpoint'; @@ -257,6 +261,8 @@ enum ClaimsEnum: string case Keys = 'keys'; + case LastUpdated = 'last_updated'; + case Length = 'length'; case Locale = 'locale'; @@ -276,6 +282,8 @@ enum ClaimsEnum: string case Name = 'name'; + case Next = 'next'; + case Nonce = 'nonce'; case NonceEndpoint = 'nonce_endpoint'; @@ -434,6 +442,9 @@ enum ClaimsEnum: string // TransactionCode case TxCode = 'tx_code'; + // UI Infos + case UiInfos = 'ui_infos'; + // UserInterfaceLocalesSupported case UiLocalesSupported = 'ui_locales_supported'; diff --git a/src/Federation.php b/src/Federation.php index 713bf6a..9e88ddf 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -19,13 +19,14 @@ use SimpleSAML\OpenID\Factories\DateIntervalDecoratorFactory; use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerDecoratorFactory; -use SimpleSAML\OpenID\Federation\CacheEntityCollectionStore; -use SimpleSAML\OpenID\Federation\EntityCollectionBuilder; -use SimpleSAML\OpenID\Federation\EntityCollectionFetcher; -use SimpleSAML\OpenID\Federation\EntityCollectionFilter; -use SimpleSAML\OpenID\Federation\EntityCollectionPaginator; -use SimpleSAML\OpenID\Federation\EntityCollectionSorter; -use SimpleSAML\OpenID\Federation\EntityCollectionStoreInterface; +use SimpleSAML\OpenID\Federation\EntityCollection\CacheEntityCollectionStore; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFetcher; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionResponseFactory; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; +use SimpleSAML\OpenID\Federation\EntityCollection\InMemoryEntityCollectionStore; use SimpleSAML\OpenID\Federation\EntityStatementFetcher; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory; @@ -35,7 +36,6 @@ use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkStatusResponseFactory; use SimpleSAML\OpenID\Federation\FederationDiscovery; -use SimpleSAML\OpenID\Federation\InMemoryEntityCollectionStore; use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\SubordinateListingFetcher; @@ -43,7 +43,6 @@ use SimpleSAML\OpenID\Federation\TrustMarkFetcher; use SimpleSAML\OpenID\Federation\TrustMarkStatusResponseFetcher; use SimpleSAML\OpenID\Federation\TrustMarkValidator; -use SimpleSAML\OpenID\Helpers; use SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory; use SimpleSAML\OpenID\Jws\Factories\JwsDecoratorBuilderFactory; use SimpleSAML\OpenID\Jws\Factories\JwsVerifierDecoratorFactory; @@ -85,7 +84,7 @@ class Federation protected ?EntityCollectionPaginator $entityCollectionPaginator = null; - protected ?EntityCollectionBuilder $entityCollectionBuilder = null; + protected ?EntityCollectionResponseFactory $entityCollectionBuilder = null; protected ?EntityStatementFetcher $entityStatementFetcher = null; @@ -154,6 +153,7 @@ public function __construct( // phpcs:ignore protected readonly TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum = TrustMarkStatusEndpointUsagePolicyEnum::NotUsed, int $maxDiscoveryDepth = 10, + protected ?EntityCollectionStoreInterface $entityCollectionStore = null, ) { $this->maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration); $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() @@ -360,17 +360,30 @@ public function subordinateListingFetcher(): SubordinateListingFetcher } - public function federationDiscovery(?EntityCollectionStoreInterface $store = null): FederationDiscovery + public function entityCollectionStore(): EntityCollectionStoreInterface { - if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) { - $effectiveStore = $store ?? ($this->cacheDecorator() instanceof \SimpleSAML\OpenID\Decorators\CacheDecorator - ? new CacheEntityCollectionStore($this->cacheDecorator()) - : new InMemoryEntityCollectionStore()); + if ($this->entityCollectionStore instanceof Federation\EntityCollection\EntityCollectionStoreInterface) { + return $this->entityCollectionStore; + } + + return $this->entityCollectionStore = + $this->cacheDecorator() instanceof \SimpleSAML\OpenID\Decorators\CacheDecorator ? + new CacheEntityCollectionStore( + $this->cacheDecorator(), + $this->helpers(), + $this->logger, + ) : + new InMemoryEntityCollectionStore(); + } + + public function federationDiscovery(): FederationDiscovery + { + if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) { $this->federationDiscovery = new FederationDiscovery( $this->entityStatementFetcher(), $this->subordinateListingFetcher(), - $effectiveStore, + $this->entityCollectionStore(), $this->maxCacheDurationDecorator(), $this->logger, $this->maxDiscoveryDepth, @@ -405,18 +418,16 @@ public function entityCollectionSorter(): EntityCollectionSorter public function entityCollectionPaginator(): EntityCollectionPaginator { - return $this->entityCollectionPaginator ??= new EntityCollectionPaginator(); + return $this->entityCollectionPaginator ??= new EntityCollectionPaginator( + $this->helpers(), + ); } - /** - * @param \SimpleSAML\OpenID\Federation\EntityCollectionStoreInterface|null $store Forwarded to - * federationDiscovery() - */ - public function entityCollectionBuilder(?EntityCollectionStoreInterface $store = null): EntityCollectionBuilder + public function entityCollectionResponseFactory(): EntityCollectionResponseFactory { - return $this->entityCollectionBuilder ??= new EntityCollectionBuilder( - $this->federationDiscovery($store), + return $this->entityCollectionBuilder ??= new EntityCollectionResponseFactory( + $this->federationDiscovery(), $this->entityCollectionFilter(), $this->entityCollectionSorter(), $this->entityCollectionPaginator(), diff --git a/src/Federation/CacheEntityCollectionStore.php b/src/Federation/CacheEntityCollectionStore.php deleted file mode 100644 index dac93ea..0000000 --- a/src/Federation/CacheEntityCollectionStore.php +++ /dev/null @@ -1,64 +0,0 @@ -cacheDecorator->set( - json_encode($entityIds, JSON_THROW_ON_ERROR), - $ttl, - self::PREFIX, - $trustAnchorId, - ); - } catch (Throwable) { - // Log if needed, or ignore for now as per ArtifactFetcher pattern - } - } - - - public function getEntityIds(string $trustAnchorId): ?array - { - try { - /** @var ?string $cached */ - $cached = $this->cacheDecorator->get(null, self::PREFIX, $trustAnchorId); - - if (is_null($cached)) { - return null; - } - - /** @var non-empty-string[] $decoded */ - $decoded = json_decode($cached, true, 512, JSON_THROW_ON_ERROR); - - return $decoded; - } catch (Throwable) { - return null; - } - } - - - public function clearEntityIds(string $trustAnchorId): void - { - try { - $this->cacheDecorator->delete(self::PREFIX, $trustAnchorId); - } catch (Throwable) { - // Ignore - } - } -} diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index 41d690f..a421165 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -10,7 +10,16 @@ class EntityCollection * @param array $entities Keyed by entity ID */ public function __construct( - public readonly array $entities, + protected readonly array $entities, ) { } + + + /** + * @return array + */ + public function all(): array + { + return $this->entities; + } } diff --git a/src/Federation/EntityCollection/CacheEntityCollectionStore.php b/src/Federation/EntityCollection/CacheEntityCollectionStore.php new file mode 100644 index 0000000..c9c995e --- /dev/null +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -0,0 +1,77 @@ +cacheDecorator->set( + $this->helpers->json()->encode($entityIds), + $ttl, + self::PREFIX, + $trustAnchorId, + ); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to store entity IDs in cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'entityIds' => $entityIds, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + public function getEntityIds(string $trustAnchorId): ?array + { + try { + /** @var ?string $cached */ + $cached = $this->cacheDecorator->get(null, self::PREFIX, $trustAnchorId); + + if (is_null($cached)) { + return null; + } + + $decoded = $this->helpers->json()->decode($cached); + return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to retrieve entity IDs from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + return null; + } + } + + + public function clearEntityIds(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::PREFIX, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to clear entity IDs from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + } + } +} diff --git a/src/Federation/EntityCollectionEntry.php b/src/Federation/EntityCollection/EntityCollectionEntry.php similarity index 65% rename from src/Federation/EntityCollectionEntry.php rename to src/Federation/EntityCollection/EntityCollectionEntry.php index 7d4fe64..099875e 100644 --- a/src/Federation/EntityCollectionEntry.php +++ b/src/Federation/EntityCollection/EntityCollectionEntry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; use JsonSerializable; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -10,15 +10,15 @@ class EntityCollectionEntry implements JsonSerializable { /** - * @param non-empty-string $entityId - * @param non-empty-string[] $entityTypes - * @param array|null $uiInfo Logo, display name, etc. + * @param non-empty-string $entityId + * @param non-empty-string[] $entityTypes + * @param array|null $uiInfos Logo, display name, etc. * @param array>|null $trustMarks */ public function __construct( public readonly string $entityId, public readonly array $entityTypes, - public readonly ?array $uiInfo = null, + public readonly ?array $uiInfos = null, public readonly ?array $trustMarks = null, ) { } @@ -28,19 +28,19 @@ public function __construct( * @return array{ * entity_id: non-empty-string, * entity_types: non-empty-string[], - * ui_info?: array, + * ui_infos?: array, * trust_marks?: array> * } */ public function jsonSerialize(): array { $data = [ - 'entity_id' => $this->entityId, + ClaimsEnum::EntityId->value => $this->entityId, ClaimsEnum::EntityTypes->value => $this->entityTypes, ]; - if (!is_null($this->uiInfo)) { - $data['ui_info'] = $this->uiInfo; + if (!is_null($this->uiInfos)) { + $data[ClaimsEnum::UiInfos->value] = $this->uiInfos; } if (!is_null($this->trustMarks)) { diff --git a/src/Federation/EntityCollectionFetcher.php b/src/Federation/EntityCollection/EntityCollectionFetcher.php similarity index 70% rename from src/Federation/EntityCollectionFetcher.php rename to src/Federation/EntityCollection/EntityCollectionFetcher.php index 21da886..260aa44 100644 --- a/src/Federation/EntityCollectionFetcher.php +++ b/src/Federation/EntityCollection/EntityCollectionFetcher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; @@ -14,9 +14,9 @@ class EntityCollectionFetcher { public function __construct( - private readonly ArtifactFetcher $artifactFetcher, - private readonly Helpers $helpers, - private readonly ?LoggerInterface $logger = null, + protected readonly ArtifactFetcher $artifactFetcher, + protected readonly Helpers $helpers, + protected readonly ?LoggerInterface $logger = null, ) { } @@ -47,21 +47,26 @@ public function fetch(string $endpointUri, array $filters = []): EntityCollectio try { $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); - /** @var mixed $decoded */ - $decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + $decoded = $this->helpers->json()->decode($responseBody); - if (!is_array($decoded) || !isset($decoded['entities']) || !is_array($decoded['entities'])) { + if ( + !is_array($decoded) || + !isset($decoded[ClaimsEnum::Entities->value]) || + !is_array($decoded[ClaimsEnum::Entities->value]) + ) { throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); } $entries = []; - foreach ($decoded['entities'] as $entryData) { + foreach ($decoded[ClaimsEnum::Entities->value] as $entryData) { if (!is_array($entryData)) { continue; } /** @var array|null $uiInfo */ - $uiInfo = is_array($entryData['ui_info'] ?? null) ? $entryData['ui_info'] : null; + $uiInfo = is_array($entryData[ClaimsEnum::UiInfos->value] ?? null) ? + $entryData[ClaimsEnum::UiInfos->value] : + null; /** @var array>|null $trustMarks */ $trustMarks = is_array($entryData[ClaimsEnum::TrustMarks->value] ?? null) ? $entryData[ClaimsEnum::TrustMarks->value] @@ -71,19 +76,22 @@ public function fetch(string $endpointUri, array $filters = []): EntityCollectio $this->helpers->type()->ensureNonEmptyString($entryData[ClaimsEnum::Id->value] ?? null), $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings( $entryData[ClaimsEnum::EntityTypes->value] ?? [], - 'entity_types', + ClaimsEnum::EntityTypes->value, ), $uiInfo, $trustMarks, ); } - $lastUpdated = $decoded['last_updated'] ?? null; + $next = is_string($next = $decoded[ClaimsEnum::Next->value] ?? null) ? $next : null; + $lastUpdated = is_numeric($lastUpdated = $decoded[ClaimsEnum::LastUpdated->value] ?? null) ? + $this->helpers->type()->ensureInt($lastUpdated) : + null; return new EntityCollectionResponse( $entries, - $this->helpers->type()->getNonEmptyStringOrNull($decoded['next'] ?? null), - is_numeric($lastUpdated) ? (int)$lastUpdated : null, + $next, + $lastUpdated, ); } catch (Throwable $throwable) { $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); diff --git a/src/Federation/EntityCollectionFilter.php b/src/Federation/EntityCollection/EntityCollectionFilter.php similarity index 89% rename from src/Federation/EntityCollectionFilter.php rename to src/Federation/EntityCollection/EntityCollectionFilter.php index e09bad5..e3ae62f 100644 --- a/src/Federation/EntityCollectionFilter.php +++ b/src/Federation/EntityCollection/EntityCollectionFilter.php @@ -2,15 +2,17 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Federation\EntityCollection; +use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Helpers; class EntityCollectionFilter { public function __construct( - private readonly Helpers $helpers, + protected readonly Helpers $helpers, ) { } @@ -25,9 +27,9 @@ public function __construct( * @return array Filtered * entity configurations keyed by entity ID */ - public function filter(EntityCollection $collection, array $criteria): array + public function filter(EntityCollection $entityCollection, array $criteria): array { - $filtered = $collection->entities; + $filtered = $entityCollection->all(); // 1. entity_type if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { @@ -85,13 +87,13 @@ public function filter(EntityCollection $collection, array $criteria): array continue; } - $displayNameValue = $typePayload['display_name'] ?? ''; + $displayNameValue = $typePayload[ClaimsEnum::DisplayName->value] ?? ''; $displayName = mb_strtolower(is_string($displayNameValue) ? $displayNameValue : ''); if ($displayName !== '' && str_contains($displayName, $q)) { return true; } - $orgNameValue = $typePayload['organization_name'] ?? ''; + $orgNameValue = $typePayload[ClaimsEnum::OrganizationName->value] ?? ''; $orgName = mb_strtolower(is_string($orgNameValue) ? $orgNameValue : ''); if ($orgName !== '' && str_contains($orgName, $q)) { return true; diff --git a/src/Federation/EntityCollectionPaginator.php b/src/Federation/EntityCollection/EntityCollectionPaginator.php similarity index 56% rename from src/Federation/EntityCollectionPaginator.php rename to src/Federation/EntityCollection/EntityCollectionPaginator.php index 72b6aa6..729a145 100644 --- a/src/Federation/EntityCollectionPaginator.php +++ b/src/Federation/EntityCollection/EntityCollectionPaginator.php @@ -2,10 +2,19 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; + +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Helpers; class EntityCollectionPaginator { + public function __construct( + protected readonly Helpers $helpers, + ) { + } + + /** * @template T * @param array $entities Full ordered result set (pre-sorted) @@ -19,12 +28,10 @@ public function paginate(array $entities, int $limit, ?string $from = null): arr $offset = 0; if (!is_null($from)) { - $fromId = base64_decode($from, true); - if ($fromId !== false) { - $index = array_search($fromId, $keys, true); - if ($index !== false) { - $offset = $index + 1; - } + $fromId = $this->helpers->base64Url()->decode($from); + $index = array_search($fromId, $keys, true); + if ($index !== false) { + $offset = $index + 1; } } @@ -33,12 +40,14 @@ public function paginate(array $entities, int $limit, ?string $from = null): arr if ($offset + $limit < count($keys)) { $lastIdInPage = array_key_last($pageItems); - $next = base64_encode((string)$lastIdInPage); + if ($lastIdInPage !== null) { + $next = $this->helpers->base64Url()->encode((string)$lastIdInPage); + } } return [ - 'entities' => $pageItems, - 'next' => $next, + ClaimsEnum::Entities->value => $pageItems, + ClaimsEnum::Next->value => $next, ]; } } diff --git a/src/Federation/EntityCollectionResponse.php b/src/Federation/EntityCollection/EntityCollectionResponse.php similarity index 61% rename from src/Federation/EntityCollectionResponse.php rename to src/Federation/EntityCollection/EntityCollectionResponse.php index f291627..c281238 100644 --- a/src/Federation/EntityCollectionResponse.php +++ b/src/Federation/EntityCollection/EntityCollectionResponse.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; use JsonSerializable; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; class EntityCollectionResponse implements JsonSerializable { - /** @param \SimpleSAML\OpenID\Federation\EntityCollectionEntry[] $entities */ + /** @param \SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionEntry[] $entities */ public function __construct( public readonly array $entities, public readonly ?string $next = null, @@ -19,7 +20,7 @@ public function __construct( /** * @return array{ - * entities: \SimpleSAML\OpenID\Federation\EntityCollectionEntry[], + * entities: \SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionEntry[], * next?: string, * last_updated?: int * } @@ -27,15 +28,15 @@ public function __construct( public function jsonSerialize(): array { $data = [ - 'entities' => $this->entities, + ClaimsEnum::Entities->value => $this->entities, ]; if (!is_null($this->next)) { - $data['next'] = $this->next; + $data[ClaimsEnum::Next->value] = $this->next; } if (!is_null($this->lastUpdated)) { - $data['last_updated'] = $this->lastUpdated; + $data[ClaimsEnum::LastUpdated->value] = $this->lastUpdated; } return $data; diff --git a/src/Federation/EntityCollectionBuilder.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php similarity index 88% rename from src/Federation/EntityCollectionBuilder.php rename to src/Federation/EntityCollection/EntityCollectionResponseFactory.php index 62a35fd..361c60c 100644 --- a/src/Federation/EntityCollectionBuilder.php +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -2,15 +2,18 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; -class EntityCollectionBuilder +use SimpleSAML\OpenID\Federation\EntityCollection; +use SimpleSAML\OpenID\Federation\FederationDiscovery; + +class EntityCollectionResponseFactory { public function __construct( - private readonly FederationDiscovery $federationDiscovery, - private readonly EntityCollectionFilter $filter, - private readonly EntityCollectionSorter $sorter, - private readonly EntityCollectionPaginator $paginator, + protected readonly FederationDiscovery $federationDiscovery, + protected readonly EntityCollectionFilter $filter, + protected readonly EntityCollectionSorter $sorter, + protected readonly EntityCollectionPaginator $paginator, ) { } diff --git a/src/Federation/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php similarity index 90% rename from src/Federation/EntityCollectionSorter.php rename to src/Federation/EntityCollection/EntityCollectionSorter.php index 7f75dcc..d986328 100644 --- a/src/Federation/EntityCollectionSorter.php +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -2,14 +2,15 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; +use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Helpers; class EntityCollectionSorter { public function __construct( - private readonly Helpers $helpers, + protected readonly Helpers $helpers, ) { } diff --git a/src/Federation/EntityCollectionStoreInterface.php b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php similarity index 93% rename from src/Federation/EntityCollectionStoreInterface.php rename to src/Federation/EntityCollection/EntityCollectionStoreInterface.php index b2cb11f..0c4920b 100644 --- a/src/Federation/EntityCollectionStoreInterface.php +++ b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; interface EntityCollectionStoreInterface { diff --git a/src/Federation/InMemoryEntityCollectionStore.php b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php similarity index 94% rename from src/Federation/InMemoryEntityCollectionStore.php rename to src/Federation/EntityCollection/InMemoryEntityCollectionStore.php index 90f53e5..cab06a0 100644 --- a/src/Federation/InMemoryEntityCollectionStore.php +++ b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace SimpleSAML\OpenID\Federation; +namespace SimpleSAML\OpenID\Federation\EntityCollection; class InMemoryEntityCollectionStore implements EntityCollectionStoreInterface { diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index ca4a701..df51807 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -6,17 +6,18 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use Throwable; class FederationDiscovery { public function __construct( - private readonly EntityStatementFetcher $entityStatementFetcher, - private readonly SubordinateListingFetcher $subordinateListingFetcher, - private readonly EntityCollectionStoreInterface $store, - private readonly DateIntervalDecorator $maxCacheDurationDecorator, - private readonly ?LoggerInterface $logger = null, - private readonly int $maxDepth = 10, + protected readonly EntityStatementFetcher $entityStatementFetcher, + protected readonly SubordinateListingFetcher $subordinateListingFetcher, + protected readonly EntityCollectionStoreInterface $entityCollectionStore, + protected readonly DateIntervalDecorator $maxCacheDurationDecorator, + protected readonly ?LoggerInterface $logger = null, + protected readonly int $maxDepth = 10, ) { } @@ -38,10 +39,10 @@ public function discoverEntities( bool $forceRefresh = false, ): array { if ($forceRefresh) { - $this->store->clearEntityIds($trustAnchorId); + $this->entityCollectionStore->clearEntityIds($trustAnchorId); } - $cachedIds = $this->store->getEntityIds($trustAnchorId); + $cachedIds = $this->entityCollectionStore->getEntityIds($trustAnchorId); if (is_array($cachedIds)) { $this->logger?->debug('Returning discovered entity IDs from store.', ['trustAnchorId' => $trustAnchorId]); return $cachedIds; @@ -66,7 +67,7 @@ public function discoverEntities( $taConfig->getExpirationTime(), ); - $this->store->storeEntityIds($trustAnchorId, $discoveredIds, $ttl); + $this->entityCollectionStore->storeEntityIds($trustAnchorId, $discoveredIds, $ttl); $this->logger?->info('Federation discovery completed.', [ 'trustAnchorId' => $trustAnchorId, 'discoveredCount' => count($discoveredIds), diff --git a/src/Federation/SubordinateListingFetcher.php b/src/Federation/SubordinateListingFetcher.php index eb2c65f..aa59008 100644 --- a/src/Federation/SubordinateListingFetcher.php +++ b/src/Federation/SubordinateListingFetcher.php @@ -14,9 +14,9 @@ class SubordinateListingFetcher { public function __construct( - private readonly ArtifactFetcher $artifactFetcher, - private readonly Helpers $helpers, - private readonly ?LoggerInterface $logger = null, + protected readonly ArtifactFetcher $artifactFetcher, + protected readonly Helpers $helpers, + protected readonly ?LoggerInterface $logger = null, ) { } @@ -40,8 +40,7 @@ public function fetch(string $listEndpointUri, array $filters = []): array $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); $this->logger?->debug('Fetched subordinate listing from network.', ['uri' => $uri]); - /** @var mixed $decoded */ - $decoded = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + $decoded = $this->helpers->json()->decode($responseBody); if (!is_array($decoded)) { throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); diff --git a/src/Helpers/Type.php b/src/Helpers/Type.php index 64ef407..fa281ec 100644 --- a/src/Helpers/Type.php +++ b/src/Helpers/Type.php @@ -57,19 +57,6 @@ public function ensureNonEmptyString(mixed $value, ?string $context = null): str } - /** - * @return non-empty-string|null - */ - public function getNonEmptyStringOrNull(mixed $value): ?string - { - if (is_string($value) && $value !== '') { - return $value; - } - - return null; - } - - /** * @return mixed[] * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException From f37c39cc9f9564ed6ff671aa77081c27d9c8a96f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 23 Apr 2026 14:49:06 +0200 Subject: [PATCH 04/16] WIP --- docs/1-openid.md | 1 + docs/5-federation-discovery.md | 436 +++++++++++++++++++++++++ src/Federation/FederationDiscovery.php | 14 +- 3 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 docs/5-federation-discovery.md diff --git a/docs/1-openid.md b/docs/1-openid.md index b088151..60e9059 100644 --- a/docs/1-openid.md +++ b/docs/1-openid.md @@ -3,3 +3,4 @@ 1. [Installation](2-installation.md) 2. [OpenID Federation Tools](3-federation.md) 3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md) +4. [Federation Discovery and Entity Collection](5-federation-discovery.md) diff --git a/docs/5-federation-discovery.md b/docs/5-federation-discovery.md new file mode 100644 index 0000000..6aaac25 --- /dev/null +++ b/docs/5-federation-discovery.md @@ -0,0 +1,436 @@ +# Federation Discovery and Entity Collection + +This library provides tools for discovering entities within an OpenID Federation +and for working with the Entity Collection Endpoint. The functionality is split +into two main areas: + +1. **Federation Discovery** — Top-down traversal of a federation hierarchy to + collect all entity IDs. +2. **Entity Collection** — Client-side fetching from a remote + `federation_collection_endpoint`, and server-side building blocks (filtering, + sorting, pagination) for implementing your own collection endpoint. + +All components are accessible through the `\SimpleSAML\OpenID\Federation` facade. + +## Setup + +Federation discovery extends the standard `Federation` instantiation with two +additional constructor parameters: + +```php + **Note**: The store tracks only the list of entity IDs per Trust Anchor, not +> the Entity Configurations themselves. Entity Configurations are fetched +> dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()`, +> which already handles JWS-level caching and respects expiry. + +## Federation Discovery + +Federation Discovery performs a top-down traversal of the federation hierarchy. +Starting from a Trust Anchor, it follows `federation_list_endpoint` links on +each entity to collect all subordinate entity IDs recursively. + +### Discovering Entity IDs + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$trustAnchorId = 'https://trust-anchor.example.org/'; + +try { + // Discover all entity IDs in the federation. + $entityIds = $federationTools->federationDiscovery() + ->discoverEntities($trustAnchorId); + + // $entityIds is an array of entity ID strings, e.g.: + // ['https://trust-anchor.example.org/', 'https://intermediate.example.org/', ...] +} catch (\Throwable $exception) { + $logger->error('Federation discovery failed: ' . $exception->getMessage()); +} +``` + +The discovery algorithm: + +1. Fetches the Entity Configuration of the Trust Anchor. +2. Extracts the `federation_list_endpoint` from its metadata. +3. Calls the subordinate listing endpoint to get immediate subordinate IDs. +4. For each subordinate, fetches its Entity Configuration and, if it has its own + `federation_list_endpoint`, recurses (up to `maxDiscoveryDepth`). +5. Deduplicates all collected entity IDs. +6. Persists the ID list in the store with a TTL based on the Trust Anchor's + expiry and the configured `maxCacheDuration`. + +### Applying Filters During Discovery + +You can pass filter parameters (e.g. `entity_type`) to the subordinate listing +endpoint: + +```php +$entityIds = $federationTools->federationDiscovery() + ->discoverEntities( + $trustAnchorId, + filters: ['entity_type' => 'openid_relying_party'], + ); +``` + +### Discovering and Fetching Entity Configurations + +The convenience method `discoverAndFetch()` performs discovery and then fetches +the Entity Configuration for each discovered entity: + +```php +try { + // Returns array keyed by entity ID. + $entities = $federationTools->federationDiscovery() + ->discoverAndFetch($trustAnchorId); + + foreach ($entities as $entityId => $entityStatement) { + $metadata = $entityStatement->getMetadata(); + // ... + } +} catch (\Throwable $exception) { + $logger->error('Discovery failed: ' . $exception->getMessage()); +} +``` + +> **Note**: Entity Configurations are fetched through the existing +> `EntityStatementFetcher`, which caches JWS at the network level. If a cached +> configuration has expired, a fresh one is fetched automatically. + +### Periodic Refresh (Cron / Background Jobs) + +Use the `forceRefresh` parameter to clear the stored entity ID list and +re-traverse the federation. This is the intended pattern for cron or background +refresh jobs: + +```php +// In a scheduled task / cron job: +$federationTools->federationDiscovery() + ->discoverAndFetch($trustAnchorId, forceRefresh: true); +``` + +When `forceRefresh` is `true`: + +- The full federation traversal is re-executed. +- The new entity ID list is stored. +- Entity Configurations that haven't expired in the JWS cache are served from + cache; only stale or new ones trigger network requests. + +## Entity Collection Client + +The Entity Collection Client fetches from a remote +`federation_collection_endpoint` and deserializes the response into typed +objects. + +### Fetching from a Remote Endpoint + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$collectionEndpointUri = 'https://trust-anchor.example.org/federation_collection'; + +try { + $response = $federationTools->entityCollectionFetcher() + ->fetch($collectionEndpointUri); + + // Iterate over the entries. + foreach ($response->entities as $entry) { + echo $entry->entityId . PHP_EOL; + echo 'Types: ' . implode(', ', $entry->entityTypes) . PHP_EOL; + + if ($entry->uiInfos !== null) { + echo 'Display: ' . ($entry->uiInfos['display_name'] ?? 'N/A') . PHP_EOL; + } + } + + // Check if there are more pages. + if ($response->next !== null) { + // Fetch next page using the cursor. + $nextPage = $federationTools->entityCollectionFetcher() + ->fetch($collectionEndpointUri, ['from' => $response->next]); + } +} catch (\Throwable $exception) { + $logger->error('Entity collection fetch failed: ' . $exception->getMessage()); +} +``` + +### Applying Filters + +The `fetch()` method accepts filter parameters as defined by the Entity +Collection Endpoint specification: + +```php +$response = $federationTools->entityCollectionFetcher()->fetch( + $collectionEndpointUri, + [ + 'entity_type' => ['openid_provider', 'openid_relying_party'], + 'trust_mark_type' => 'https://example.com/trust-mark/member', + 'query' => 'university', + 'limit' => 20, + ], +); +``` + +Multi-value parameters (like `entity_type`) are serialized as repeated query +keys (`?entity_type=openid_provider&entity_type=openid_relying_party`) per the +specification. + +### Response Objects + +- **`EntityCollectionResponse`** — Contains the `entities` array, + an optional `next` cursor for pagination, and an optional `lastUpdated` + timestamp. Implements `JsonSerializable`. +- **`EntityCollectionEntry`** — Represents a single entity in the collection. + Contains `entityId`, `entityTypes`, optional `uiInfos`, and optional + `trustMarks`. Implements `JsonSerializable`. + +## Server-Side Building Blocks + +If you want to implement and serve your own `federation_collection_endpoint`, +this library provides building-block components that handle the core logic. You +only need to wire them into your HTTP framework's controller. + +### Overview + +The server-side pipeline follows this order: + +1. **Discover** — Collect entities from the federation. +2. **Filter** — Apply client-requested filters (entity type, trust mark, query). +3. **Sort** — Order by a metadata claim (e.g. `display_name`). +4. **Project** — Select only the requested UI claims. +5. **Paginate** — Slice the result set and produce a cursor. +6. **Serialize** — Return a `JsonSerializable` response. + +### Using EntityCollectionResponseFactory + +The `EntityCollectionResponseFactory` is a convenience orchestrator that wires +all the above steps into a single call: + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$trustAnchorId = 'https://trust-anchor.example.org/'; + +// In your controller, pass the incoming request parameters directly. +$requestParams = $request->getQueryParams(); + +$response = $federationTools->entityCollectionResponseFactory() + ->build($trustAnchorId, $requestParams); + +// The response implements JsonSerializable. +return new JsonResponse(json_encode($response)); +``` + +Supported request parameters: + +| Parameter | Type | Description | +|---|---|---| +| `entity_type` | `string[]` | Filter by entity type keys (e.g. `openid_provider`) | +| `trust_mark_type` | `string` | Filter by Trust Mark type | +| `query` | `string` | Free-text search on entity ID, `display_name`, `organization_name` | +| `trust_anchor` | `string` | Filter by Trust Anchor (via `authority_hints`) | +| `sort_by` | `string` | Dot-separated claim path (e.g. `federation_entity.display_name`) | +| `sort_dir` | `'asc'\|'desc'` | Sort direction, defaults to `asc` | +| `ui_claims` | `string[]` | Claims to include in the `ui_infos` projection | +| `limit` | `int` | Maximum entries per page (default 100) | +| `from` | `string` | Opaque cursor from a previous response's `next` field | + +### Using Individual Components + +You can also use each building block independently for maximum control. + +#### EntityCollectionFilter + +Filters entity configurations by various criteria: + +```php +use SimpleSAML\OpenID\Federation\EntityCollection; + +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +// Prepare a collection from discovery or any other source. +$entities = $federationTools->federationDiscovery() + ->discoverAndFetch($trustAnchorId); +$collection = new EntityCollection($entities); + +// Filter by entity type and text query. +$filtered = $federationTools->entityCollectionFilter()->filter( + $collection, + [ + 'entity_type' => ['openid_provider'], + 'query' => 'university', + ], +); + +// $filtered is array keyed by entity ID. +``` + +#### EntityCollectionSorter + +Sorts entities by a metadata claim value: + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +// Sort by display_name under the federation_entity metadata. +$sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim( + $filtered, // array + ['federation_entity', 'display_name'], + 'asc', +); + +// Sort by organization_name under the openid_provider metadata. +$sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim( + $filtered, + ['openid_provider', 'organization_name'], + 'desc', +); +``` + +Entities missing the specified claim are placed at the end of the result set. + +#### EntityCollectionPaginator + +Slices a pre-sorted result set into a page with an opaque cursor: + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$paginated = $federationTools->entityCollectionPaginator()->paginate( + $sorted, // Pre-sorted array + 20, // Limit (page size) + null, // Cursor from a previous response's 'next' value, or null +); + +$pageEntities = $paginated['entities']; // array +$nextCursor = $paginated['next']; // ?string — null when on the last page +``` + +The `next` cursor is an opaque base64url-encoded pointer. Pass it as the `from` +parameter in the next request to continue pagination. + +## Full Server-Side Example + +Here is a complete example of wiring the building blocks into a controller +action: + +```php +federationTools + ->entityCollectionResponseFactory() + ->build($this->trustAnchorId, $request->getQueryParams()); + + // EntityCollectionResponse implements JsonSerializable. + return json_encode($response, JSON_THROW_ON_ERROR); + } +} +``` + +Example request: + +``` +GET /federation_collection?entity_type=openid_provider&query=university&sort_by=federation_entity.display_name&limit=10 +``` + +Example response: + +```json +{ + "entities": [ + { + "entity_id": "https://idp.university-a.example.org/", + "entity_types": ["openid_provider"], + "ui_infos": { + "display_name": "University A Identity Provider" + } + }, + { + "entity_id": "https://idp.university-b.example.org/", + "entity_types": ["openid_provider"], + "ui_infos": { + "display_name": "University B Identity Provider" + } + } + ], + "next": "aHR0cHM6Ly9pZHAudW5pdmVyc2l0eS1iLmV4YW1wbGUub3JnLw", + "last_updated": 1745410000 +} +``` diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index df51807..27bff40 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -38,14 +38,12 @@ public function discoverEntities( array $filters = [], bool $forceRefresh = false, ): array { - if ($forceRefresh) { - $this->entityCollectionStore->clearEntityIds($trustAnchorId); - } - - $cachedIds = $this->entityCollectionStore->getEntityIds($trustAnchorId); - if (is_array($cachedIds)) { - $this->logger?->debug('Returning discovered entity IDs from store.', ['trustAnchorId' => $trustAnchorId]); - return $cachedIds; + if (!$forceRefresh) { + $cachedIds = $this->entityCollectionStore->getEntityIds($trustAnchorId); + if (is_array($cachedIds)) { + $this->logger?->debug('Returning discovered entity IDs from store.', ['trustAnchorId' => $trustAnchorId]); + return $cachedIds; + } } $this->logger?->info( From 6e90dd76eab35093bf566bd4a57baa10f4e0b208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 24 Apr 2026 12:27:42 +0200 Subject: [PATCH 05/16] WIP --- docs/5-federation-discovery.md | 87 ++++++-------- src/Federation/EntityCollection.php | 4 +- .../CacheEntityCollectionStore.php | 26 +++-- .../EntityCollectionFilter.php | 45 +++---- .../EntityCollectionResponseFactory.php | 32 ++--- .../EntityCollectionSorter.php | 16 ++- .../EntityCollectionStoreInterface.php | 18 +-- .../InMemoryEntityCollectionStore.php | 12 +- src/Federation/FederationDiscovery.php | 110 ++++++++---------- 9 files changed, 167 insertions(+), 183 deletions(-) diff --git a/docs/5-federation-discovery.md b/docs/5-federation-discovery.md index 6aaac25..13f2e0e 100644 --- a/docs/5-federation-discovery.md +++ b/docs/5-federation-discovery.md @@ -61,27 +61,26 @@ The store interface is minimal: interface EntityCollectionStoreInterface { /** - * Persist discovered entity IDs for a given Trust Anchor. + * Persist discovered entities for a given Trust Anchor. */ - public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void; + public function store(string $trustAnchorId, array $entities, int $ttl): void; /** - * Retrieve previously discovered entity IDs. + * Retrieve previously discovered entities. * Return null when not found or expired. */ - public function getEntityIds(string $trustAnchorId): ?array; + public function get(string $trustAnchorId): ?array; /** - * Remove stored entity IDs (for force re-discovery). + * Remove stored entities (for force re-discovery). */ - public function clearEntityIds(string $trustAnchorId): void; + public function clear(string $trustAnchorId): void; } ``` -> **Note**: The store tracks only the list of entity IDs per Trust Anchor, not -> the Entity Configurations themselves. Entity Configurations are fetched -> dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()`, -> which already handles JWS-level caching and respects expiry. +> **Note**: The store tracks the JWT payload arrays per Trust Anchor. +> Entity Configurations are fetched dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()` +> during the traversal process, which handles JWS-level caching and respects expiry. ## Federation Discovery @@ -89,7 +88,7 @@ Federation Discovery performs a top-down traversal of the federation hierarchy. Starting from a Trust Anchor, it follows `federation_list_endpoint` links on each entity to collect all subordinate entity IDs recursively. -### Discovering Entity IDs +### Discovering Entities ```php /** @var \SimpleSAML\OpenID\Federation $federationTools */ @@ -97,12 +96,15 @@ each entity to collect all subordinate entity IDs recursively. $trustAnchorId = 'https://trust-anchor.example.org/'; try { - // Discover all entity IDs in the federation. - $entityIds = $federationTools->federationDiscovery() - ->discoverEntities($trustAnchorId); + // Discover all entities (ID -> payload map) in the federation. + $entities = $federationTools->federationDiscovery() + ->discover($trustAnchorId); - // $entityIds is an array of entity ID strings, e.g.: - // ['https://trust-anchor.example.org/', 'https://intermediate.example.org/', ...] + // $entities is an array keyed by entity ID, where values are JWT payload arrays: + // [ + // 'https://trust-anchor.example.org/' => ['iss' => '...', 'metadata' => [...]], + // ... + // ] } catch (\Throwable $exception) { $logger->error('Federation discovery failed: ' . $exception->getMessage()); } @@ -115,63 +117,46 @@ The discovery algorithm: 3. Calls the subordinate listing endpoint to get immediate subordinate IDs. 4. For each subordinate, fetches its Entity Configuration and, if it has its own `federation_list_endpoint`, recurses (up to `maxDiscoveryDepth`). -5. Deduplicates all collected entity IDs. -6. Persists the ID list in the store with a TTL based on the Trust Anchor's +5. Deduplicates all collected entities. +6. Persists the entity payloads in the store with a TTL based on the Trust Anchor's expiry and the configured `maxCacheDuration`. +If you only need the list of entity IDs without their payloads, use the convenience method: + +```php +$entityIds = $federationTools->federationDiscovery() + ->discoverEntityIds($trustAnchorId); +``` + ### Applying Filters During Discovery You can pass filter parameters (e.g. `entity_type`) to the subordinate listing endpoint: ```php -$entityIds = $federationTools->federationDiscovery() - ->discoverEntities( +$entities = $federationTools->federationDiscovery() + ->discover( $trustAnchorId, filters: ['entity_type' => 'openid_relying_party'], ); ``` -### Discovering and Fetching Entity Configurations - -The convenience method `discoverAndFetch()` performs discovery and then fetches -the Entity Configuration for each discovered entity: - -```php -try { - // Returns array keyed by entity ID. - $entities = $federationTools->federationDiscovery() - ->discoverAndFetch($trustAnchorId); - - foreach ($entities as $entityId => $entityStatement) { - $metadata = $entityStatement->getMetadata(); - // ... - } -} catch (\Throwable $exception) { - $logger->error('Discovery failed: ' . $exception->getMessage()); -} -``` - -> **Note**: Entity Configurations are fetched through the existing -> `EntityStatementFetcher`, which caches JWS at the network level. If a cached -> configuration has expired, a fresh one is fetched automatically. - ### Periodic Refresh (Cron / Background Jobs) -Use the `forceRefresh` parameter to clear the stored entity ID list and +Use the `forceRefresh` parameter to clear the stored entities and re-traverse the federation. This is the intended pattern for cron or background refresh jobs: ```php // In a scheduled task / cron job: $federationTools->federationDiscovery() - ->discoverAndFetch($trustAnchorId, forceRefresh: true); + ->discover($trustAnchorId, forceRefresh: true); ``` When `forceRefresh` is `true`: - The full federation traversal is re-executed. -- The new entity ID list is stored. +- The new entity payload map is stored. - Entity Configurations that haven't expired in the JWS cache are served from cache; only stale or new ones trigger network requests. @@ -309,7 +294,7 @@ use SimpleSAML\OpenID\Federation\EntityCollection; // Prepare a collection from discovery or any other source. $entities = $federationTools->federationDiscovery() - ->discoverAndFetch($trustAnchorId); + ->discover($trustAnchorId); $collection = new EntityCollection($entities); // Filter by entity type and text query. @@ -321,7 +306,7 @@ $filtered = $federationTools->entityCollectionFilter()->filter( ], ); -// $filtered is array keyed by entity ID. +// $filtered is array> keyed by entity ID. ``` #### EntityCollectionSorter @@ -333,7 +318,7 @@ Sorts entities by a metadata claim value: // Sort by display_name under the federation_entity metadata. $sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim( - $filtered, // array + $filtered, // array> ['federation_entity', 'display_name'], 'asc', ); @@ -356,7 +341,7 @@ Slices a pre-sorted result set into a page with an opaque cursor: /** @var \SimpleSAML\OpenID\Federation $federationTools */ $paginated = $federationTools->entityCollectionPaginator()->paginate( - $sorted, // Pre-sorted array + $sorted, // Pre-sorted array|EntityCollectionEntry> 20, // Limit (page size) null, // Cursor from a previous response's 'next' value, or null ); diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index a421165..512ea01 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -7,7 +7,7 @@ class EntityCollection { /** - * @param array $entities Keyed by entity ID + * @param array> $entities Keyed by entity ID, value is JWT payload */ public function __construct( protected readonly array $entities, @@ -16,7 +16,7 @@ public function __construct( /** - * @return array + * @return array> */ public function all(): array { diff --git a/src/Federation/EntityCollection/CacheEntityCollectionStore.php b/src/Federation/EntityCollection/CacheEntityCollectionStore.php index c9c995e..2978045 100644 --- a/src/Federation/EntityCollection/CacheEntityCollectionStore.php +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -11,7 +11,7 @@ class CacheEntityCollectionStore implements EntityCollectionStoreInterface { - protected const PREFIX = 'federation_entity_ids'; + protected const PREFIX = 'federation_entities'; public function __construct( @@ -22,26 +22,26 @@ public function __construct( } - public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void + public function store(string $trustAnchorId, array $entities, int $ttl): void { try { $this->cacheDecorator->set( - $this->helpers->json()->encode($entityIds), + $this->helpers->json()->encode($entities), $ttl, self::PREFIX, $trustAnchorId, ); } catch (Throwable $throwable) { - $this->logger?->error('Unable to store entity IDs in cache.', [ + $this->logger?->error('Unable to store entities in cache.', [ 'trustAnchorId' => $trustAnchorId, - 'entityIds' => $entityIds, + 'entities' => $entities, 'exception_message' => $throwable->getMessage(), ]); } } - public function getEntityIds(string $trustAnchorId): ?array + public function get(string $trustAnchorId): ?array { try { /** @var ?string $cached */ @@ -52,9 +52,15 @@ public function getEntityIds(string $trustAnchorId): ?array } $decoded = $this->helpers->json()->decode($cached); - return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded); + + if (!is_array($decoded)) { + return null; + } + + /** @var array> $decoded */ + return $decoded; } catch (Throwable $throwable) { - $this->logger?->error('Unable to retrieve entity IDs from cache.', [ + $this->logger?->error('Unable to retrieve entities from cache.', [ 'trustAnchorId' => $trustAnchorId, 'exception_message' => $throwable->getMessage(), ]); @@ -63,12 +69,12 @@ public function getEntityIds(string $trustAnchorId): ?array } - public function clearEntityIds(string $trustAnchorId): void + public function clear(string $trustAnchorId): void { try { $this->cacheDecorator->delete(self::PREFIX, $trustAnchorId); } catch (Throwable $throwable) { - $this->logger?->error('Unable to clear entity IDs from cache.', [ + $this->logger?->error('Unable to clear entities from cache.', [ 'trustAnchorId' => $trustAnchorId, 'exception_message' => $throwable->getMessage(), ]); diff --git a/src/Federation/EntityCollection/EntityCollectionFilter.php b/src/Federation/EntityCollection/EntityCollectionFilter.php index e3ae62f..5359be0 100644 --- a/src/Federation/EntityCollection/EntityCollectionFilter.php +++ b/src/Federation/EntityCollection/EntityCollectionFilter.php @@ -6,7 +6,6 @@ use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Federation\EntityCollection; -use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Helpers; class EntityCollectionFilter @@ -24,8 +23,8 @@ public function __construct( * query?: string, * trust_anchor?: string, * } $criteria - * @return array Filtered - * entity configurations keyed by entity ID + * @return array> Filtered + * entity payloads keyed by entity ID */ public function filter(EntityCollection $entityCollection, array $criteria): array { @@ -34,8 +33,12 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr // 1. entity_type if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { $types = $criteria['entity_type']; - $filtered = array_filter($filtered, function (EntityStatement $statement) use ($types): bool { - $metadata = $statement->getMetadata(); + $filtered = array_filter($filtered, function (array $payload) use ($types): bool { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { + return false; + } + foreach ($types as $type) { if (isset($metadata[$type])) { return true; @@ -49,18 +52,14 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr // 2. trust_mark_type if (isset($criteria['trust_mark_type'])) { $tmType = $criteria['trust_mark_type']; - $filtered = array_filter($filtered, function (EntityStatement $statement) use ($tmType): bool { - try { - $marks = $statement->getTrustMarks(); - if ($marks instanceof \SimpleSAML\OpenID\Federation\Claims\TrustMarksClaimBag) { - foreach ($marks->getAll() as $mark) { - if ($mark->getTrustMarkType() === $tmType) { - return true; - } + $filtered = array_filter($filtered, function (array $payload) use ($tmType): bool { + $marks = $payload[ClaimsEnum::TrustMarks->value] ?? null; + if (is_array($marks)) { + foreach ($marks as $mark) { + if (is_array($mark) && ($mark[ClaimsEnum::TrustMarkType->value] ?? null) === $tmType) { + return true; } } - } catch (\Throwable) { - return false; } return false; @@ -70,14 +69,16 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr // 3. query if (isset($criteria['query']) && $criteria['query'] !== '') { $q = mb_strtolower($criteria['query']); - $filtered = array_filter($filtered, function (EntityStatement $statement) use ($q): bool { - $sub = mb_strtolower($statement->getSubject()); - if (str_contains($sub, $q)) { + $filtered = array_filter($filtered, function (array $payload) use ($q): bool { + $sub = is_string($payload[ClaimsEnum::Sub->value] ?? null) ? + mb_strtolower($payload[ClaimsEnum::Sub->value]) : + ''; + if ($sub !== '' && str_contains($sub, $q)) { return true; } - $metadata = $statement->getMetadata(); - if ($metadata === null) { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { return false; } @@ -111,11 +112,11 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr // filter on the authority hint if possible. if (isset($criteria['trust_anchor'])) { $ta = $criteria['trust_anchor']; - $filtered = array_filter($filtered, function (EntityStatement $statement) use ($ta): bool { + $filtered = array_filter($filtered, function (array $payload) use ($ta): bool { // In a top-down traversal, everything is subordinate to the TA we started with. // If the collection contains multiple TAs, we would check authority_hints. $hints = $this->helpers->arr()->getNestedValue( - $statement->getPayload(), + $payload, ClaimsEnum::AuthorityHints->value, ); if (is_array($hints)) { diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php index 361c60c..b159f56 100644 --- a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -4,6 +4,7 @@ namespace SimpleSAML\OpenID\Federation\EntityCollection; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Federation\EntityCollection; use SimpleSAML\OpenID\Federation\FederationDiscovery; @@ -37,8 +38,8 @@ public function __construct( */ public function build(string $trustAnchorId, array $requestParams = []): EntityCollectionResponse { - // 1. Discover and fetch full configurations - $entities = $this->federationDiscovery->discoverAndFetch($trustAnchorId); + // 1. Discover full configurations + $entities = $this->federationDiscovery->discover($trustAnchorId); $collection = new EntityCollection($entities); // 2. Filter @@ -59,8 +60,12 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC $entries = []; $uiClaims = $requestParams['ui_claims'] ?? null; - foreach ($filtered as $id => $statement) { - $metadata = $statement->getMetadata() ?? []; + foreach ($filtered as $id => $payload) { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; + if (!is_array($metadata)) { + $metadata = []; + } + /** @var non-empty-string[] $entityTypes */ $entityTypes = array_keys($metadata); @@ -68,14 +73,14 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC $uiInfo = null; if (is_array($uiClaims) && $uiClaims !== []) { $uiInfo = []; - foreach ($metadata as $payload) { - if (!is_array($payload)) { + foreach ($metadata as $typePayload) { + if (!is_array($typePayload)) { continue; } foreach ($uiClaims as $claim) { - if (isset($payload[$claim])) { - $uiInfo[$claim] = $payload[$claim]; + if (isset($typePayload[$claim])) { + $uiInfo[$claim] = $typePayload[$claim]; } } } @@ -83,11 +88,10 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC // trust_marks projection is handled by getting them from statement $trustMarks = null; - try { - // In a real projection, we might filter which trust marks to return, - // but for now we return all if asked or if no specific selection is implemented. - $trustMarks = $statement->getTrustMarks(); - } catch (\Throwable) { + $marks = $payload[ClaimsEnum::TrustMarks->value] ?? null; + if (is_array($marks)) { + /** @var array> $marks */ + $trustMarks = $marks; } // If entity_claims is provided, we might want to filter the metadata itself, @@ -98,7 +102,7 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC $id, $entityTypes, $uiInfo, - $trustMarks?->jsonSerialize(), + $trustMarks, ); } diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php index d986328..78bc979 100644 --- a/src/Federation/EntityCollection/EntityCollectionSorter.php +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -4,7 +4,7 @@ namespace SimpleSAML\OpenID\Federation\EntityCollection; -use SimpleSAML\OpenID\Federation\EntityStatement; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Helpers; class EntityCollectionSorter @@ -18,11 +18,11 @@ public function __construct( /** * Sort entities by a claim nested inside their metadata. * - * @param array $entities Keyed by entity ID + * @param array> $entities Keyed by entity ID * @param non-empty-string[] $claimPath Nested claim path within the metadata * object (e.g. ['federation_entity', 'display_name']) * @param 'asc'|'desc' $direction - * @return array Sorted copy + * @return array> Sorted copy */ public function sortByMetadataClaim( array $entities, @@ -33,9 +33,13 @@ public function sortByMetadataClaim( return []; } - uasort($entities, function (EntityStatement $a, EntityStatement $b) use ($claimPath, $direction): int { - $metadataA = $a->getMetadata() ?? []; - $metadataB = $b->getMetadata() ?? []; + uasort($entities, function (array $a, array $b) use ($claimPath, $direction): int { + $metadataA = $a[ClaimsEnum::Metadata->value] ?? []; + $metadataA = is_array($metadataA) ? $metadataA : []; + + $metadataB = $b[ClaimsEnum::Metadata->value] ?? []; + $metadataB = is_array($metadataB) ? $metadataB : []; + $valA = $this->helpers->arr()->getNestedValue($metadataA, ...$claimPath); $valB = $this->helpers->arr()->getNestedValue($metadataB, ...$claimPath); diff --git a/src/Federation/EntityCollection/EntityCollectionStoreInterface.php b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php index 0c4920b..6b93ef2 100644 --- a/src/Federation/EntityCollection/EntityCollectionStoreInterface.php +++ b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php @@ -7,27 +7,27 @@ interface EntityCollectionStoreInterface { /** - * Persist the discovered entity IDs for a given Trust Anchor. + * Persist discovered entities (ID → payload) for a given Trust Anchor. * - * @param non-empty-string $trustAnchorId - * @param non-empty-string[] $entityIds + * @param non-empty-string $trustAnchorId + * @param array> $entities Keyed by entity ID, value is JWT payload */ - public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void; + public function store(string $trustAnchorId, array $entities, int $ttl): void; /** - * Retrieve previously discovered entity IDs for a Trust Anchor. + * Retrieve previously discovered entities. * * @param non-empty-string $trustAnchorId - * @return non-empty-string[]|null null when not found / expired + * @return array>|null null when not found / expired */ - public function getEntityIds(string $trustAnchorId): ?array; + public function get(string $trustAnchorId): ?array; /** - * Remove all stored entity IDs for a Trust Anchor (force re-discovery). + * Remove stored entities (force re-discovery). * * @param non-empty-string $trustAnchorId */ - public function clearEntityIds(string $trustAnchorId): void; + public function clear(string $trustAnchorId): void; } diff --git a/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php index cab06a0..b382fe1 100644 --- a/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php +++ b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php @@ -6,20 +6,20 @@ class InMemoryEntityCollectionStore implements EntityCollectionStoreInterface { - /** @var array */ + /** @var array>, expires: int}> */ private array $store = []; - public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void + public function store(string $trustAnchorId, array $entities, int $ttl): void { $this->store[$trustAnchorId] = [ - 'ids' => $entityIds, + 'entities' => $entities, 'expires' => time() + $ttl, ]; } - public function getEntityIds(string $trustAnchorId): ?array + public function get(string $trustAnchorId): ?array { if (!isset($this->store[$trustAnchorId])) { return null; @@ -30,11 +30,11 @@ public function getEntityIds(string $trustAnchorId): ?array return null; } - return $this->store[$trustAnchorId]['ids']; + return $this->store[$trustAnchorId]['entities']; } - public function clearEntityIds(string $trustAnchorId): void + public function clear(string $trustAnchorId): void { unset($this->store[$trustAnchorId]); } diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index 27bff40..08db841 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -23,26 +23,29 @@ public function __construct( /** - * Discover all entity IDs in the federation rooted at $trustAnchorId. + * Discover all entities (ID -> payload map) in the federation rooted at $trustAnchorId. * Results are stored in the EntityCollectionStoreInterface and returned. * * @param non-empty-string $trustAnchorId * @param array $filters Passed through to * SubordinateListingFetcher - * @param bool $forceRefresh If true, ignore stored entity IDs and + * @param bool $forceRefresh If true, ignore stored entities and * re-traverse the federation - * @return non-empty-string[] + * @return array> */ - public function discoverEntities( + public function discover( string $trustAnchorId, array $filters = [], bool $forceRefresh = false, ): array { if (!$forceRefresh) { - $cachedIds = $this->entityCollectionStore->getEntityIds($trustAnchorId); - if (is_array($cachedIds)) { - $this->logger?->debug('Returning discovered entity IDs from store.', ['trustAnchorId' => $trustAnchorId]); - return $cachedIds; + $cachedEntities = $this->entityCollectionStore->get($trustAnchorId); + if (is_array($cachedEntities)) { + $this->logger?->debug( + 'Returning discovered entities from store.', + ['trustAnchorId' => $trustAnchorId], + ); + return $cachedEntities; } } @@ -51,24 +54,23 @@ public function discoverEntities( ['trustAnchorId' => $trustAnchorId, 'filters' => $filters], ); - $discoveredIds = []; + $discoveredEntities = []; try { // Step 1: Fetch TA config $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); // Recursive traversal - $discoveredIds = $this->traverse($trustAnchorId, $taConfig, $filters); - $discoveredIds = array_unique($discoveredIds); + $discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters); // Compute TTL: lowest of maxCacheDuration and TA expiry $ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime( $taConfig->getExpirationTime(), ); - $this->entityCollectionStore->storeEntityIds($trustAnchorId, $discoveredIds, $ttl); + $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); $this->logger?->info('Federation discovery completed.', [ 'trustAnchorId' => $trustAnchorId, - 'discoveredCount' => count($discoveredIds), + 'discoveredCount' => count($discoveredEntities), ]); } catch (Throwable $throwable) { $this->logger?->error('Federation discovery failed.', [ @@ -77,7 +79,23 @@ public function discoverEntities( ]); } - return $discoveredIds; + return $discoveredEntities; + } + + + /** + * Discover just the entity IDs in the federation. + * + * @param non-empty-string $trustAnchorId + * @param array $filters + * @return string[] + */ + public function discoverEntityIds( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): array { + return array_keys($this->discover($trustAnchorId, $filters, $forceRefresh)); } @@ -85,7 +103,7 @@ public function discoverEntities( * @param non-empty-string $entityId * @param array $filters * @param string[] $visited - * @return non-empty-string[] + * @return array> */ private function traverse( string $entityId, @@ -99,21 +117,26 @@ private function traverse( } $visited[] = $entityId; - $allCollectedIds = [$entityId]; + $allCollectedEntities = [$entityId => $entityConfig->getPayload()]; $listEndpoint = $entityConfig->getFederationListEndpoint(); if (is_null($listEndpoint)) { - return $allCollectedIds; + return $allCollectedEntities; } try { $subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters); foreach ($subordinateIds as $subId) { + // If we've already visited this subId (loop), skip to avoid infinite recursion + if (in_array($subId, $visited, true)) { + continue; + } + try { $subConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($subId); - $allCollectedIds = array_merge( - $allCollectedIds, + $allCollectedEntities = array_merge( + $allCollectedEntities, $this->traverse($subId, $subConfig, $filters, $depth + 1, $visited), ); } catch (Throwable $e) { @@ -122,8 +145,10 @@ private function traverse( 'subId' => $subId, 'error' => $e->getMessage(), ]); - // Still include the ID if we discovered it from the list - $allCollectedIds[] = $subId; + // Still include the ID if we discovered it from the list, but with an empty payload + if (!isset($allCollectedEntities[$subId])) { + $allCollectedEntities[$subId] = []; + } } } } catch (Throwable $throwable) { @@ -133,47 +158,6 @@ private function traverse( ]); } - return $allCollectedIds; - } - - - /** - * Return Entity Configurations for the given entity IDs, fetched from cache or network. - * - * @param non-empty-string[] $entityIds - * @return array keyed by entity ID - */ - public function fetchEntityConfigurations(array $entityIds): array - { - $entities = []; - foreach ($entityIds as $id) { - try { - $entities[$id] = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($id); - } catch (Throwable $e) { - $this->logger?->warning('Failed to fetch entity configuration.', [ - 'entityId' => $id, - 'error' => $e->getMessage(), - ]); - } - } - - return $entities; - } - - - /** - * Convenience: discover entity IDs then fetch their Entity Configurations. - * - * @param non-empty-string $trustAnchorId - * @param array $filters - * @return array - */ - public function discoverAndFetch( - string $trustAnchorId, - array $filters = [], - bool $forceRefresh = false, - ): array { - $ids = $this->discoverEntities($trustAnchorId, $filters, $forceRefresh); - return $this->fetchEntityConfigurations($ids); + return $allCollectedEntities; } } From 558434b10487d6b80b9348f4326c253581c61adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 24 Apr 2026 13:42:04 +0200 Subject: [PATCH 06/16] WIP --- src/Federation/FederationDiscovery.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index 08db841..c143685 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -42,7 +42,7 @@ public function discover( $cachedEntities = $this->entityCollectionStore->get($trustAnchorId); if (is_array($cachedEntities)) { $this->logger?->debug( - 'Returning discovered entities from store.', + 'Returning discovered entities from entity collection store.', ['trustAnchorId' => $trustAnchorId], ); return $cachedEntities; From cac6ce2a87c8a86b879ff9492cb6adff11df3909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 24 Apr 2026 20:51:07 +0200 Subject: [PATCH 07/16] WIP --- .../CacheEntityCollectionStore.php | 80 ++++++++++++++++++- .../EntityCollectionStoreInterface.php | 21 +++++ .../InMemoryEntityCollectionStore.php | 23 +++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/Federation/EntityCollection/CacheEntityCollectionStore.php b/src/Federation/EntityCollection/CacheEntityCollectionStore.php index 2978045..04fe61d 100644 --- a/src/Federation/EntityCollection/CacheEntityCollectionStore.php +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -11,7 +11,9 @@ class CacheEntityCollectionStore implements EntityCollectionStoreInterface { - protected const PREFIX = 'federation_entities'; + protected const KEY_FEDERATED_ENTITIES = 'federation_entities'; + + protected const KEY_LAST_UPDATED = 'last_updated'; public function __construct( @@ -22,13 +24,16 @@ public function __construct( } + /** + * @inheritDoc + */ public function store(string $trustAnchorId, array $entities, int $ttl): void { try { $this->cacheDecorator->set( $this->helpers->json()->encode($entities), $ttl, - self::PREFIX, + self::KEY_FEDERATED_ENTITIES, $trustAnchorId, ); } catch (Throwable $throwable) { @@ -41,11 +46,14 @@ public function store(string $trustAnchorId, array $entities, int $ttl): void } + /** + * @inheritDoc + */ public function get(string $trustAnchorId): ?array { try { /** @var ?string $cached */ - $cached = $this->cacheDecorator->get(null, self::PREFIX, $trustAnchorId); + $cached = $this->cacheDecorator->get(null, self::KEY_FEDERATED_ENTITIES, $trustAnchorId); if (is_null($cached)) { return null; @@ -69,10 +77,13 @@ public function get(string $trustAnchorId): ?array } + /** + * @inheritDoc + */ public function clear(string $trustAnchorId): void { try { - $this->cacheDecorator->delete(self::PREFIX, $trustAnchorId); + $this->cacheDecorator->delete(self::KEY_FEDERATED_ENTITIES, $trustAnchorId); } catch (Throwable $throwable) { $this->logger?->error('Unable to clear entities from cache.', [ 'trustAnchorId' => $trustAnchorId, @@ -80,4 +91,65 @@ public function clear(string $trustAnchorId): void ]); } } + + + /** + * @inheritDoc + */ + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void + { + try { + $this->cacheDecorator->set( + (string)$timestamp, + $ttl, + self::KEY_LAST_UPDATED, + $trustAnchorId, + ); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to store last updated timestamp in cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'timestamp' => $timestamp, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @inheritDoc + */ + public function getLastUpdated(string $trustAnchorId): ?int + { + try { + $lastUpdated = $this->cacheDecorator->get(null, self::KEY_LAST_UPDATED, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to retrieve last updated timestamp from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + return null; + } + + if (is_int($lastUpdated)) { + return $lastUpdated; + } + + return null; + } + + + /** + * @inheritDoc + */ + public function clearLastUpdated(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::KEY_LAST_UPDATED, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to clear last updated timestamp from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + } + } } diff --git a/src/Federation/EntityCollection/EntityCollectionStoreInterface.php b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php index 6b93ef2..e8e9914 100644 --- a/src/Federation/EntityCollection/EntityCollectionStoreInterface.php +++ b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php @@ -30,4 +30,25 @@ public function get(string $trustAnchorId): ?array; * @param non-empty-string $trustAnchorId */ public function clear(string $trustAnchorId): void; + + + /** + * Set the last update timestamp for a given trust anchor. + * + * @param non-empty-string $trustAnchorId + */ + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void; + + + /** + * Get the last update timestamp for a given trust anchor. + * @param non-empty-string $trustAnchorId + */ + public function getLastUpdated(string $trustAnchorId): ?int; + + + /** + * Clear the last update timestamp for a given trust anchor. + */ + public function clearLastUpdated(string $trustAnchorId): void; } diff --git a/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php index b382fe1..13358c7 100644 --- a/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php +++ b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php @@ -7,7 +7,10 @@ class InMemoryEntityCollectionStore implements EntityCollectionStoreInterface { /** @var array>, expires: int}> */ - private array $store = []; + protected array $store = []; + + /** @var array */ + protected array $lastUpdatedStore = []; public function store(string $trustAnchorId, array $entities, int $ttl): void @@ -38,4 +41,22 @@ public function clear(string $trustAnchorId): void { unset($this->store[$trustAnchorId]); } + + + public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void + { + $this->lastUpdatedStore[$trustAnchorId] = $timestamp; + } + + + public function getLastUpdated(string $trustAnchorId): ?int + { + return $this->lastUpdatedStore[$trustAnchorId] ?? null; + } + + + public function clearLastUpdated(string $trustAnchorId): void + { + unset($this->lastUpdatedStore[$trustAnchorId]); + } } From 19b0f8d69af784c9977c987cff29f3e36f06e691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 24 Apr 2026 21:32:16 +0200 Subject: [PATCH 08/16] WIP --- src/Federation.php | 1 + .../EntityCollection/EntityCollectionResponseFactory.php | 7 ++++--- src/Federation/FederationDiscovery.php | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Federation.php b/src/Federation.php index 9e88ddf..15c3f2c 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -431,6 +431,7 @@ public function entityCollectionResponseFactory(): EntityCollectionResponseFacto $this->entityCollectionFilter(), $this->entityCollectionSorter(), $this->entityCollectionPaginator(), + $this->entityCollectionStore(), ); } diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php index b159f56..20694db 100644 --- a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -15,6 +15,7 @@ public function __construct( protected readonly EntityCollectionFilter $filter, protected readonly EntityCollectionSorter $sorter, protected readonly EntityCollectionPaginator $paginator, + protected readonly EntityCollectionStoreInterface $entityCollectionStore, ) { } @@ -115,9 +116,9 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC $paginated = $this->paginator->paginate($entries, $limit, $from); return new EntityCollectionResponse( - array_values($paginated['entities']), - $paginated['next'], - time(), // last_updated + entities: array_values($paginated['entities']), + next: $paginated['next'], + lastUpdated: $this->entityCollectionStore->getLastUpdated($trustAnchorId) ?? time(), ); } } diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index c143685..0a390b9 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -68,6 +68,8 @@ public function discover( ); $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); + $this->entityCollectionStore->storeLastUpdated($trustAnchorId, time(), $ttl); + $this->logger?->info('Federation discovery completed.', [ 'trustAnchorId' => $trustAnchorId, 'discoveredCount' => count($discoveredEntities), From f7c65676e6af9eb51aeaf84947363e97d5ab43f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 24 Apr 2026 21:43:33 +0200 Subject: [PATCH 09/16] WIP --- .../CacheEntityCollectionStore.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Federation/EntityCollection/CacheEntityCollectionStore.php b/src/Federation/EntityCollection/CacheEntityCollectionStore.php index 04fe61d..e38cd6d 100644 --- a/src/Federation/EntityCollection/CacheEntityCollectionStore.php +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -31,7 +31,7 @@ public function store(string $trustAnchorId, array $entities, int $ttl): void { try { $this->cacheDecorator->set( - $this->helpers->json()->encode($entities), + $entities, $ttl, self::KEY_FEDERATED_ENTITIES, $trustAnchorId, @@ -52,21 +52,14 @@ public function store(string $trustAnchorId, array $entities, int $ttl): void public function get(string $trustAnchorId): ?array { try { - /** @var ?string $cached */ $cached = $this->cacheDecorator->get(null, self::KEY_FEDERATED_ENTITIES, $trustAnchorId); - if (is_null($cached)) { + if (!is_array($cached)) { return null; } - $decoded = $this->helpers->json()->decode($cached); - - if (!is_array($decoded)) { - return null; - } - - /** @var array> $decoded */ - return $decoded; + /** @var array> $cached */ + return $cached; } catch (Throwable $throwable) { $this->logger?->error('Unable to retrieve entities from cache.', [ 'trustAnchorId' => $trustAnchorId, From 00e9468a77d3cf0d702ed81a2f0d013346f42aac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 30 Apr 2026 14:55:15 +0200 Subject: [PATCH 10/16] Update specs --- ...penid-federation-entity-collection-1_0.md} | 116 +++++++++++------- specifications/update-specs.sh | 2 +- src/Helpers/Url.php | 27 ++-- 3 files changed, 87 insertions(+), 58 deletions(-) rename specifications/{openid-federation-entity-collection.md => openid-federation-entity-collection-1_0.md} (82%) diff --git a/specifications/openid-federation-entity-collection.md b/specifications/openid-federation-entity-collection-1_0.md similarity index 82% rename from specifications/openid-federation-entity-collection.md rename to specifications/openid-federation-entity-collection-1_0.md index fbcc516..4e0bffb 100644 --- a/specifications/openid-federation-entity-collection.md +++ b/specifications/openid-federation-entity-collection-1_0.md @@ -13,7 +13,7 @@ title: OpenID Federation Entity Collection Endpoint 1.0 - draft 00 viewport: initial-scale=1.0 --- - openid-federation-entity-collection March 2026 + openid-federation-entity-collection April 2026 ---------- ------------------------------------- ------------ Zachmann Standards Track \[Page\] @@ -21,7 +21,7 @@ Workgroup: : individual Published: -: 27 March 2026 +: 28 April 2026 Author: @@ -65,7 +65,7 @@ This specification acts as an extension to \[[OpenID.Federation](#OpenID.Federat - [3.4.1](#section-3.4.1).  [Response Format](#name-response-format) - - [3.4.2](#section-3.4.2).  [Response Claims](#name-response-claims) + - [3.4.2](#section-3.4.2).  [Error Response Format](#name-error-response-format) - [4](#section-4).  [Claims Languages and Scripts](#name-claims-languages-and-script) @@ -175,31 +175,31 @@ The following is a non-normative example of an Entity Configuration payload, for When client authentication is not used, the request to the `federation_collection_endpoint` MUST be an HTTP request using the GET method with the following query parameters, encoded in `application/x-www-form-urlencoded` format:[¶](#section-3.3.1-1) -- **from**: (OPTIONAL) If this parameter is present, the resulting list MUST be the subset of the overall ordered response starting from this pointer. This parameter MUST be copied from the `next` response parameter of a previous request. If the pointer in this parameter is not or not longer known to the responder, it MUST use the HTTP status code 404 and the content type `application/json` with the error code `page_not_found`.\ - If the responder does not support this feature, it MUST use the HTTP status code 400 and the content type `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.1.1) +- **from**: (OPTIONAL) If this parameter is present, the resulting list MUST be the subset of the overall ordered response starting from this pointer. This parameter MUST be copied from the `next` response parameter of a previous request. If the pointer in this parameter is not or not longer known to the responder, it MUST return an error response with the error code `page_not_found` as defined in [Error Response Format](#error-response-format).\ + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.1.1) -- **limit**: (OPTIONAL) Requested number of results included in the response. If this parameter is present, the number of results in the returned list MUST NOT be greater than the minimum of the responder's upper limit and the value of this parameter. If this parameter is not present the server MUST fall back on the upper limit.\ - If the responder does not support this feature, it MUST use the HTTP status code 400 and the content type `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.2.1) +- **limit**: (OPTIONAL) Requested number of results included in the response. If this parameter is present, the number of results in the returned list MUST NOT be greater than the minimum of the responder\'s upper limit and the value of this parameter. If this parameter is not present the server MUST fall back on the upper limit.\ + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.2.1) -- **entity_type**: (OPTIONAL) The value of this parameter is an Entity Type Identifier. The result MUST be filtered to include only those entities that include the specified Entity Type. When multiple `entity_type` parameters are present, for example `entity_type=openid_provider&entity_type=openid_relying_party`, the result MUST be filtered to include all Entities that include any of the specified Entity Types. If the responder does not support this feature, it MUST use the HTTP status code 400 and the content type `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.3.1) +- **entity_type**: (OPTIONAL) The value of this parameter is an Entity Type Identifier. The result MUST be filtered to include only those entities that include the specified Entity Type. When multiple `entity_type` parameters are present, for example `entity_type=openid_provider&entity_type=openid_relying_party`, the result MUST be filtered to include all Entities that include any of the specified Entity Types. If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.3.1) - **trust_mark_type**: (OPTIONAL) The value of this parameter is a Trust Mark Type Identifier. The result MUST be filtered to include only Entities that publish a Trust Mark of this Trust Mark Type in their Entity Configuration and that Trust Mark MUST be verified by the responder. The responder SHOULD verify the Trust Mark using the same Trust Anchor that is used to collect the Entities. When multiple `trust_mark_type` parameters are present, the result MUST be filtered to include only Entities that have a Trust Mark for all the specified Trust Mark Types.\ - If the responder does not support this feature, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.4.1) + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.4.1) -- **trust_anchor**: (RECOMMENDED) The Trust Anchor that the collection endpoint MUST use when collecting Entities. The value is an Entity Identifier. If omitted, the responder sets this parameter to its own Entity Identifier. If the responder does not have a defined Entity Identifier, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `invalid_request`.[¶](#section-3.3.1-2.5.1) +- **trust_anchor**: (RECOMMENDED) The Trust Anchor that the collection endpoint MUST use when collecting Entities. The value is an Entity Identifier. If omitted, the responder sets this parameter to its own Entity Identifier. If the responder does not have a defined Entity Identifier, it MUST return an error response with the error code `invalid_request` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.5.1) - **query**: (OPTIONAL) The value of this parameter is used by the responder to filter down the list of returned Entities to only entities that match this parameter value. It is entirely up to the responder to define when an Entity matches the query.\ - If the responder does not support this feature, it SHOULD use the HTTP status code 400 and the content type `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.6.1) + If the responder does not support this feature, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.6.1) - **entity_claims**: (OPTIONAL) Array of claims to be included in the Entity Info Object included in the response for each collected Entity.\ If this parameter is NOT present it is at the discretion of the responder which claims are included or not.\ If this parameter is present and it is NOT an empty array, each Entity Info Object that represents an Entity MUST include the requested claims unless a specific claim is not available for that Entity. Also Claims that are optional to return and not present in the array MUST NOT be included in the Entity Info.\ - If the responder does not support a requested claim, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.7.1) + If the responder does not support a requested claim, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.7.1) - **ui_claims**: (OPTIONAL) Array of claims to be included in the Entity Type UI Info Object included in the response for each returned Entity.\ If this parameter is NOT present it is at the discretion of the responder which claims are included or not.\ If this parameter is present and it is NOT an empty array, each Entity Type UI Info Object MUST include the requested claims unless a specific claim is not available for that Entity and Entity Type.\ - If the responder does not support a requested claim, it MUST use the HTTP status code 400 and set the content type to `application/json`, with the error code `unsupported_parameter`.[¶](#section-3.3.1-2.8.1) + If the responder does not support a requested claim, it MUST return an error response with the error code `unsupported_parameter` as defined in [Error Response Format](#error-response-format).[¶](#section-3.3.1-2.8.1) When Client authentication is used, the request MUST be an HTTP request using the POST method, with the parameters passed in the POST body.[¶](#section-3.3.1-3) @@ -218,53 +218,47 @@ The following is a non-normative example of a collection request:[¶](#section-3 A successful response MUST use the HTTP status code 200 and the content type `application/json`.[¶](#section-3.4.1-1) -The response is a JSON object as described below.[¶](#section-3.4.1-2) +The response is a JSON object with the following claims:[¶](#section-3.4.1-2) -If the response is negative, it will be a JSON object and the content type MUST be `application/json` and use the errors defined here or in \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.1-3) +- **entities**: (REQUIRED) Array of JSON objects, each representing a Federation Entity as described in [Entity Info](#entity-info). The list of Entities MUST only contain Entities that are in line with the requested parameters. The responder MAY also filter down the list further at its own discretion.[¶](#section-3.4.1-3.1) +- **next**: (OPTIONAL) An opaque pointer to the next page in the result list. This attribute is REQUIRED when additional results are available beyond those included in the `entities` array. To content of this attribute is entirely up to the responder and its pagination implementation strategy.[¶](#section-3.4.1-3.2) +- **last_updated**: (RECOMMENDED) Number. Time when the responder last updated the result list. This is expressed as Seconds Since the Epoch, per \[[RFC7519](#RFC7519)\]. If the `last_updated` time changes between paginated calls, this might be an indication for the client that it might have received outdated information in a previous call.[¶](#section-3.4.1-3.3) -#### [3.4.2.](#section-3.4.2) [Response Claims](#name-response-claims) +Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.1-4) -The claims in the entity collection response are:[¶](#section-3.4.2-1) +##### [3.4.1.1.](#section-3.4.1.1) [Entity Info](#name-entity-info) -- **entities**: (REQUIRED) Array of JSON objects, each representing a Federation Entity as described in [Entity Info](#entity-info). The list of Entities MUST only contain Entities that are in line with the requested parameters. The responder MAY also filter down the list further at its own discretion.[¶](#section-3.4.2-2.1) -- **next**: (OPTIONAL) An opaque pointer to the next page in the result list. This attribute is REQUIRED when additional results are available beyond those included in the `entities` array. To content of this attribute is entirely up to the responder and its pagination implementation strategy.[¶](#section-3.4.2-2.2) -- **last_updated**: (RECOMMENDED) Number. Time when the responder last updated the result list. This is expressed as Seconds Since the Epoch, per \[[RFC7519](#RFC7519)\]. If the `last_updated` time changes between paginated calls, this might be an indication for the client that it might have received outdated information in a previous call.[¶](#section-3.4.2-2.3) +Each JSON Object in the returned `entities` array MAY contain the following claims:[¶](#section-3.4.1.1-1) -Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.2-3) +- **entity_id**: (REQUIRED) The Entity Identifier for the subject entity of the current record.[¶](#section-3.4.1.1-2.1) -##### [3.4.2.1.](#section-3.4.2.1) [Entity Info](#name-entity-info) - -Each JSON Object in the returned `entities` array MAY contain the following claims:[¶](#section-3.4.2.1-1) - -- **entity_id**: (REQUIRED) The Entity Identifier for the subject entity of the current record.[¶](#section-3.4.2.1-2.1) - -- **entity_types**: (RECOMMENDED) Array of string Entity Type Identifiers. If present this claim MUST contain all Entity Type Identifiers of the subject\'s Entity the responder knows about.[¶](#section-3.4.2.1-2.2) +- **entity_types**: (RECOMMENDED) Array of string Entity Type Identifiers. If present this claim MUST contain all Entity Type Identifiers of the subject\'s Entity the responder knows about.[¶](#section-3.4.1.1-2.2) - **ui_infos**: (OPTIONAL) JSON Object containing information intended to be displayed to the user for each entity type as described in [UI Infos](#ui-infos).\ - If the request contains the `entity_type` parameter, the UI Infos Object MUST only contain Entity Type Identifiers that are among the ones requested, with the exception of the `federation_entity` Entity Type Identifier, which MAY also appear if not explicitly requested.[¶](#section-3.4.2.1-2.3.1) + If the request contains the `entity_type` parameter, the UI Infos Object MUST only contain Entity Type Identifiers that are among the ones requested, with the exception of the `federation_entity` Entity Type Identifier, which MAY also appear if not explicitly requested.[¶](#section-3.4.1.1-2.3.1) -- **trust_marks**: (OPTIONAL) Array of objects, each representing a Trust Mark, as defined in Section 3 of \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.2.1-2.4.1) +- **trust_marks**: (OPTIONAL) Array of objects, each representing a Trust Mark, as defined in Section 3 of \[[OpenID.Federation](#OpenID.Federation)\].[¶](#section-3.4.1.1-2.4.1) -Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.2.1-3) +Additional claims MAY be defined and used in conjunction with the claims above.[¶](#section-3.4.1.1-3) -###### [3.4.2.1.1.](#section-3.4.2.1.1) [UI Infos](#name-ui-infos) +###### [3.4.1.1.1.](#section-3.4.1.1.1) [UI Infos](#name-ui-infos) -UI Infos is a JSON Object containing UI-related information about a single Entity, but differentiated by its Entity Types.[¶](#section-3.4.2.1.1-1) +UI Infos is a JSON Object containing UI-related information about a single Entity, but differentiated by its Entity Types.[¶](#section-3.4.1.1.1-1) -Each member name of the JSON object is an Entity Type Identifier and each value is an Entity Type UI Info Object as defined in [Entity Type UI Info](#entity-type-ui-info).[¶](#section-3.4.2.1.1-2) +Each member name of the JSON object is an Entity Type Identifier and each value is an Entity Type UI Info Object as defined in [Entity Type UI Info](#entity-type-ui-info).[¶](#section-3.4.1.1.1-2) -###### [3.4.2.1.1.1.](#section-3.4.2.1.1.1) [Entity Type UI Info](#name-entity-type-ui-info) +###### [3.4.1.1.1.1.](#section-3.4.1.1.1.1) [Entity Type UI Info](#name-entity-type-ui-info) -Entity Type UI Info is a JSON Object containing UI-related information about a single Entity Type of an Entity.[¶](#section-3.4.2.1.1.1-1) +Entity Type UI Info is a JSON Object containing UI-related information about a single Entity Type of an Entity.[¶](#section-3.4.1.1.1.1-1) -All Claims specified in section 5.2.2 \"Informational Metadata Extensions\" of \[[OpenID.Federation](#OpenID.Federation)\] MAY be used.[¶](#section-3.4.2.1.1.1-2) +All Claims specified in section 5.2.2 \"Informational Metadata Extensions\" of \[[OpenID.Federation](#OpenID.Federation)\] MAY be used.[¶](#section-3.4.1.1.1.1-2) -Additional Claims MAY be defined and used in conjunction with the Claims above.[¶](#section-3.4.2.1.1.1-3) +Additional Claims MAY be defined and used in conjunction with the Claims above.[¶](#section-3.4.1.1.1.1-3) -##### [3.4.2.2.](#section-3.4.2.2) [Example Response](#name-example-response) +##### [3.4.1.2.](#section-3.4.1.2) [Example Response](#name-example-response) { - "federation_entities": [ + "entities": [ { "entity_id": "https://green.example.com", "entity_types": [ @@ -303,7 +297,33 @@ Additional Claims MAY be defined and used in conjunction with the Claims above.[ ] } -[¶](#section-3.4.2.2-1) +[¶](#section-3.4.1.2-1) + +#### [3.4.2.](#section-3.4.2) [Error Response Format](#name-error-response-format) + +If the request was malformed or an error occurred during the processing of the request, the response body MUST be a JSON object with the content type `application/json`. In compliance with \[[RFC6749](#RFC6749)\] and \[[OpenID.Federation](#OpenID.Federation)\], the following standardized error format MUST be used:[¶](#section-3.4.2-1) + +- **error**: (REQUIRED) Error codes in the IANA \"OAuth Extensions Error Registry\" \[[IANA.OAuth.Parameters](#IANA.OAuth.Parameters)\] MAY be used. In particular, these existing error codes are used by this specification:[¶](#section-3.4.2-2.1.1) + + - **unsupported_parameter**: The server does not support a requested parameter. The HTTP response status code SHOULD be 400 (Bad Request).[¶](#section-3.4.2-2.1.2.1) + - **invalid_request**: The request is incomplete or does not comply with current specifications. The HTTP response status code SHOULD be 400 (Bad Request).\ + \ + In addition the following error codes defined by this specification MAY be used:[¶](#section-3.4.2-2.1.2.2) + - **page_not_found**: The pagination pointer provided in the `from` parameter is not or no longer known to the responder. The HTTP response status code SHOULD be 404 (Not Found).[¶](#section-3.4.2-2.1.2.3) + +- **error_description**: (REQUIRED) Human-readable text providing additional information used to assist the developer in understanding the error that occurred.[¶](#section-3.4.2-2.2) + +The following is a non-normative example of an error response:[¶](#section-3.4.2-3) + + 400 Bad Request + Content-Type: application/json + + { + "error": "unsupported_parameter", + "error_description": "The 'limit' parameter is not supported by this endpoint." + } + +[¶](#section-3.4.2-4) ## [4.](#section-4) [Claims Languages and Scripts](#name-claims-languages-and-script) @@ -366,22 +386,26 @@ The responder is free to restrict the scope of its Entity Collection Endpoint, s ## [6.](#section-6) [Security Considerations](#name-security-considerations) -In additional to the considerations below, the security considerations of OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] apply to this specification.[¶](#section-6-1) +In addition to the considerations below, the security considerations of OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] apply to this specification.[¶](#section-6-1) ### [6.1.](#section-6.1) [Unsigned Response](#name-unsigned-response) -The response from the Entity Collection Endpoint is not signed and the obtained information MUST be considered as informational. To verify an Entity proper trust validation according to OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] still MUST be done.[¶](#section-6.1-1) +The response from the Entity Collection Endpoint is not signed and the obtained information MUST be considered as informational. To verify an Entity, proper trust validation according to OpenID Federation 1.0 \[[OpenID.Federation](#OpenID.Federation)\] still MUST be done.[¶](#section-6.1-1) It is also noted that Trust Marks returned in the response MAY not be verified and clients MUST consider them as not yet verified.[¶](#section-6.1-2) ## [7.](#section-7) [Normative References](#name-normative-references) +\[IANA.OAuth.Parameters\] +: IANA, \"OAuth Parameters\", 25 March 2026, \<\>. +: + \[OpenID.Core\] : Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., and C. Mortimore, \"OpenID Connect Core 1.0 incorporating errata set 2\", 15 December 2023, \<\>. : \[OpenID.Federation\] -: Ed., R. H., Jones, M. B., Solberg, A., Bradley, J., Marco, G. D., and V. Dzhuvinov, \"OpenID Federation 1.0\", 24 October 2024, \<\>. +: Ed., R. H., Jones, M. B., Solberg, A., Bradley, J., Marco, G. D., and V. Dzhuvinov, \"OpenID Federation 1.0\", 17 February 2026, \<\>. : \[RFC2119\] @@ -406,7 +430,7 @@ It is also noted that Trust Marks returned in the response MAY not be verified a ## [Appendix A.](#appendix-A) [Notices](#name-notices) -Copyright (c) 2025 The OpenID Foundation.[¶](#appendix-A-1) +Copyright (c) 2026 The OpenID Foundation.[¶](#appendix-A-1) The OpenID Foundation (OIDF) grants to any Contributor, developer, implementer, or other interested party a non-exclusive, royalty free, worldwide copyright license to reproduce, prepare derivative works from, distribute, perform and display, this Implementers Draft, Final Specification, or Final Specification Incorporating Errata Corrections solely for the purposes of (i) developing specifications, and (ii) implementing Implementers Drafts, Final Specifications, and Final Specification Incorporating Errata Corrections based on such documents, provided that attribution be made to the OIDF as the source of the material, but that such attribution does not indicate an endorsement by the OIDF.[¶](#appendix-A-2) @@ -414,7 +438,7 @@ The technology described in this specification was made available from contribut ## [Appendix B.](#appendix-B) [Acknowledgements](#name-acknowledgements) -We would like to thank the following individuals for their contributions to this specification: Niels van Dijk, Michael Fraser, Łukasz Jaromin, Michael B. Jones, Giuseppe De Marco, Stefan Santesson, Phil Smart, Zacharias Törnblom, and the Geant Trust & Identity Incubator of Geant5-2.[¶](#appendix-B-1) +We would like to thank the following individuals for their contributions to this specification: Niels van Dijk, Michael Fraser, Marko Ivančić, Łukasz Jaromin, Michael B. Jones, Giuseppe De Marco, Stefan Santesson, Phil Smart, Zacharias Törnblom, and the Geant Trust & Identity Incubator of Geant5-2.[¶](#appendix-B-1) ## [Appendix C.](#appendix-C) [Document History](#name-document-history) diff --git a/specifications/update-specs.sh b/specifications/update-specs.sh index 5539c4a..2c9621d 100755 --- a/specifications/update-specs.sh +++ b/specifications/update-specs.sh @@ -13,7 +13,7 @@ URLS=( # OpenID specifications "https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html" "https://openid.net/specs/openid-federation-1_0.html" - #"https://openid.github.io/federation-entity-collection/main.html" + "https://openid.net/specs/openid-federation-entity-collection-1_0.html" "https://openid.net/specs/openid-connect-core-1_0.html" "https://openid.net/specs/openid-connect-discovery-1_0.html" "https://openid.net/specs/openid-connect-rpinitiated-1_0.html" diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index aaa1a12..a6e2db3 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -33,12 +33,7 @@ public function withParams(string $url, array $params): string $queryParams = array_merge($queryParams, $params); $newQueryString = http_build_query($queryParams); - return (isset($parsedUri['scheme']) ? $parsedUri['scheme'] . '://' : '') . - ($parsedUri['host'] ?? '') . - (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . - ($parsedUri['path'] ?? '') . - '?' . $newQueryString . - (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); + return $this->prepareUri($parsedUri, $newQueryString); } @@ -89,11 +84,21 @@ public function withMultiValueParams(string $url, array $params): string $newQueryString = implode('&', $queryElements); + return $this->prepareUri($parsedUri, $newQueryString); + } + + /** + * @param false|array|int|string|null $parsedUri + * @param string $newQueryString + * @return string + */ + protected function prepareUri(false|array|int|string|null $parsedUri, string $newQueryString): string + { return (isset($parsedUri['scheme']) ? $parsedUri['scheme'] . '://' : '') . - ($parsedUri['host'] ?? '') . - (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . - ($parsedUri['path'] ?? '') . - '?' . $newQueryString . - (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); + ($parsedUri['host'] ?? '') . + (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . + ($parsedUri['path'] ?? '') . + '?' . $newQueryString . + (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); } } From cc2087dbf26d1e2f2d1befb9cd027780c1bf6bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 30 Apr 2026 16:53:44 +0200 Subject: [PATCH 11/16] WIP --- src/Federation.php | 14 +++ src/Federation/EntityCollection.php | 81 ++++++++++++++++- .../EntityCollectionFilter.php | 90 +++++++++---------- .../EntityCollectionPaginator.php | 10 +-- .../EntityCollectionResponseFactory.php | 39 +++++--- .../EntityCollectionSorter.php | 44 ++++++--- .../Factories/EntityCollectionFactory.php | 35 ++++++++ src/Federation/FederationDiscovery.php | 13 +-- src/Helpers/Url.php | 13 +-- 9 files changed, 250 insertions(+), 89 deletions(-) create mode 100644 src/Federation/Factories/EntityCollectionFactory.php diff --git a/src/Federation.php b/src/Federation.php index 15c3f2c..b877737 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -28,6 +28,7 @@ use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use SimpleSAML\OpenID\Federation\EntityCollection\InMemoryEntityCollectionStore; use SimpleSAML\OpenID\Federation\EntityStatementFetcher; +use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainBagFactory; @@ -140,6 +141,8 @@ class Federation protected ?KeyPairResolver $keyPairResolver = null; + protected ?EntityCollectionFactory $entityCollectionFactory = null; + public function __construct( protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(), @@ -377,6 +380,16 @@ public function entityCollectionStore(): EntityCollectionStoreInterface } + public function entityCollectionFactory(): EntityCollectionFactory + { + return $this->entityCollectionFactory ??= new EntityCollectionFactory( + $this->entityCollectionFilter(), + $this->entityCollectionSorter(), + $this->entityCollectionPaginator(), + ); + } + + public function federationDiscovery(): FederationDiscovery { if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) { @@ -385,6 +398,7 @@ public function federationDiscovery(): FederationDiscovery $this->subordinateListingFetcher(), $this->entityCollectionStore(), $this->maxCacheDurationDecorator(), + $this->entityCollectionFactory(), $this->logger, $this->maxDiscoveryDepth, ); diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index 512ea01..578a262 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -4,13 +4,22 @@ namespace SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; + class EntityCollection { /** - * @param array> $entities Keyed by entity ID, value is JWT payload + * @param array> $entities Keyed by entity ID, + * value is JWT payload */ public function __construct( - protected readonly array $entities, + protected readonly EntityCollectionFilter $entityCollectionFilter, + protected readonly EntityCollectionSorter $entityCollectionSorter, + protected readonly EntityCollectionPaginator $entityCollectionPaginator, + protected array $entities, + protected ?string $nextPageToken = null, ) { } @@ -18,8 +27,74 @@ public function __construct( /** * @return array> */ - public function all(): array + public function getEntities(): array { return $this->entities; } + + + /** + * Apply filters to the collection. Supported criteria keys: + * - entity_type: array of entity types to include + * (e.g. ['openid_relying_party']) + * - trust_mark_type: array of trust mark types to include + * (e.g. ['https://example.com/marks/approved']) + * - query: string to search for in display_name, organization_name, + * and entity_id (case-insensitive) + * + * @param array{ + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * } $criteria + * @return $this + */ + public function filter(array $criteria): static + { + $this->entities = $this->entityCollectionFilter->filter($this->entities, $criteria); + + return $this; + } + + + /** + * @param non-empty-array $claimPaths + * @param 'asc'|'desc' $sortOrder + * @return $this + */ + public function sortByMetadataClaims(array $claimPaths, string $sortOrder): static + { + $this->entities = $this->entityCollectionSorter->sortByMetadataClaims( + $this->entities, + $claimPaths, + $sortOrder, + ); + + return $this; + } + + + /** + * @param positive-int $limit Maximum number of entries to return + * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) + */ + public function paginate(int $limit, ?string $from = null): static + { + [ + 'entities' => $this->entities, + 'next' => $this->nextPageToken, + ] = $this->entityCollectionPaginator->paginate( + $this->entities, + $limit, + $from, + ); + + return $this; + } + + + public function getNextPageToken(): ?string + { + return $this->nextPageToken; + } } diff --git a/src/Federation/EntityCollection/EntityCollectionFilter.php b/src/Federation/EntityCollection/EntityCollectionFilter.php index 5359be0..f6a983b 100644 --- a/src/Federation/EntityCollection/EntityCollectionFilter.php +++ b/src/Federation/EntityCollection/EntityCollectionFilter.php @@ -5,7 +5,6 @@ namespace SimpleSAML\OpenID\Federation\EntityCollection; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; -use SimpleSAML\OpenID\Federation\EntityCollection; use SimpleSAML\OpenID\Helpers; class EntityCollectionFilter @@ -17,23 +16,35 @@ public function __construct( /** + * Filters a list of entities based on the provided criteria. + * + * The method applies multiple filters in the following order: + * 1. Filters entities by their type, based on the 'entity_type' criteria. + * 2. Filters entities by their trust mark type, based on the + * 'trust_mark_type' criteria. + * 3. Filters entities by a textual query that checks multiple fields + * (e.g., `display_name` or `organization_name`). + * + * @param array> $entities The list of entities + * to be filtered. Each entity is expected to be an associative array. * @param array{ - * entity_type?: string[], - * trust_mark_type?: string, - * query?: string, - * trust_anchor?: string, - * } $criteria - * @return array> Filtered - * entity payloads keyed by entity ID + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * } $criteria The array of filtering criteria. It may contain: + * - 'entity_type': An array of entity types to filter by. + * - 'trust_mark_type': An array of trust mark types to filter by. + * - 'query': A string used to perform a case-insensitive search on + * specific fields. + * @return array> The filtered list of entities + * that match all provided criteria. */ - public function filter(EntityCollection $entityCollection, array $criteria): array + public function filter(array $entities, array $criteria): array { - $filtered = $entityCollection->all(); - // 1. entity_type if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { $types = $criteria['entity_type']; - $filtered = array_filter($filtered, function (array $payload) use ($types): bool { + $entities = array_filter($entities, function (array $payload) use ($types): bool { $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; if (!is_array($metadata)) { return false; @@ -50,26 +61,35 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr } // 2. trust_mark_type - if (isset($criteria['trust_mark_type'])) { - $tmType = $criteria['trust_mark_type']; - $filtered = array_filter($filtered, function (array $payload) use ($tmType): bool { - $marks = $payload[ClaimsEnum::TrustMarks->value] ?? null; - if (is_array($marks)) { - foreach ($marks as $mark) { - if (is_array($mark) && ($mark[ClaimsEnum::TrustMarkType->value] ?? null) === $tmType) { - return true; - } + if (isset($criteria['trust_mark_type']) && $criteria['trust_mark_type'] !== []) { + $criteriaTrustMarkTypes = $criteria['trust_mark_type']; + $entities = array_filter($entities, function (array $payload) use ($criteriaTrustMarkTypes): bool { + $entityTrustMarks = $payload[ClaimsEnum::TrustMarks->value] ?? null; + if (!is_array($entityTrustMarks)) { + return false; + } + + $entityTrustMarkTypes = []; + foreach ($entityTrustMarks as $mark) { + if (is_array($mark) && isset($mark[ClaimsEnum::TrustMarkType->value])) { + $entityTrustMarkTypes[] = $mark[ClaimsEnum::TrustMarkType->value]; } } - return false; + foreach ($criteriaTrustMarkTypes as $tmType) { + if (!in_array($tmType, $entityTrustMarkTypes, true)) { + return false; + } + } + + return true; }); } // 3. query if (isset($criteria['query']) && $criteria['query'] !== '') { $q = mb_strtolower($criteria['query']); - $filtered = array_filter($filtered, function (array $payload) use ($q): bool { + $entities = array_filter($entities, function (array $payload) use ($q): bool { $sub = is_string($payload[ClaimsEnum::Sub->value] ?? null) ? mb_strtolower($payload[ClaimsEnum::Sub->value]) : ''; @@ -105,28 +125,6 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr }); } - // 4. trust_anchor (simple prefix match for now as per spec suggestion, - // or more complex if needed). Historically, in some federation - // implementations, subordination is indicated via id prefix or - // specific claims. For this building block, we'll implement it as a - // filter on the authority hint if possible. - if (isset($criteria['trust_anchor'])) { - $ta = $criteria['trust_anchor']; - $filtered = array_filter($filtered, function (array $payload) use ($ta): bool { - // In a top-down traversal, everything is subordinate to the TA we started with. - // If the collection contains multiple TAs, we would check authority_hints. - $hints = $this->helpers->arr()->getNestedValue( - $payload, - ClaimsEnum::AuthorityHints->value, - ); - if (is_array($hints)) { - return in_array($ta, $hints, true); - } - - return false; - }); - } - - return $filtered; + return $entities; } } diff --git a/src/Federation/EntityCollection/EntityCollectionPaginator.php b/src/Federation/EntityCollection/EntityCollectionPaginator.php index 729a145..614b3b9 100644 --- a/src/Federation/EntityCollection/EntityCollectionPaginator.php +++ b/src/Federation/EntityCollection/EntityCollectionPaginator.php @@ -16,11 +16,11 @@ public function __construct( /** - * @template T - * @param array $entities Full ordered result set (pre-sorted) - * @param positive-int $limit Maximum number of entries to return - * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) - * @return array{entities: array, next: ?string} + * @param array> $entities The list of entities + * to be paginate, ordered (pre-sorted). + * @param positive-int $limit Maximum number of entries to return + * @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER) + * @return array{entities: array>, next: ?string} */ public function paginate(array $entities, int $limit, ?string $from = null): array { diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php index 20694db..714458f 100644 --- a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -29,7 +29,7 @@ public function __construct( * trust_mark_type?: string, * query?: string, * trust_anchor?: string, - * sort_by?: string, + * sort_by?: string|string[], * sort_dir?: 'asc'|'desc', * entity_claims?: string[], * ui_claims?: string[], @@ -41,27 +41,44 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC { // 1. Discover full configurations $entities = $this->federationDiscovery->discover($trustAnchorId); - $collection = new EntityCollection($entities); + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities->getEntities(), + ); // 2. Filter - $filtered = $this->filter->filter($collection, $requestParams); + $collection->filter($requestParams); // 3. Sort if (isset($requestParams['sort_by'])) { - $path = explode('.', $requestParams['sort_by']); - /** @var non-empty-string[] $path */ - $filtered = $this->sorter->sortByMetadataClaim( - $filtered, - $path, - (string)($requestParams['sort_dir'] ?? 'asc'), - ); + $sortByParams = is_array($requestParams['sort_by']) + ? $requestParams['sort_by'] + : [$requestParams['sort_by']]; + + $claimPaths = []; + foreach ($sortByParams as $sortBy) { + if (!is_string($sortBy)) { + continue; + } + $claimPaths[] = explode('.', $sortBy); + } + + if ($claimPaths !== []) { + /** @var non-empty-array $claimPaths */ + $collection->sortByMetadataClaims( + $claimPaths, + (string)($requestParams['sort_dir'] ?? 'asc'), + ); + } } // 4. Claims sub-selection (Projection) $entries = []; $uiClaims = $requestParams['ui_claims'] ?? null; - foreach ($filtered as $id => $payload) { + foreach ($collection->getEntities() as $id => $payload) { $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; if (!is_array($metadata)) { $metadata = []; diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php index 78bc979..ade0790 100644 --- a/src/Federation/EntityCollection/EntityCollectionSorter.php +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -5,6 +5,7 @@ namespace SimpleSAML\OpenID\Federation\EntityCollection; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\Helpers; class EntityCollectionSorter @@ -16,40 +17,57 @@ public function __construct( /** - * Sort entities by a claim nested inside their metadata. + * Sort entities by one or more claims nested inside their metadata. * * @param array> $entities Keyed by entity ID - * @param non-empty-string[] $claimPath Nested claim path within the metadata - * object (e.g. ['federation_entity', 'display_name']) + * @param non-empty-array $claimPaths Array of + * nested claim paths within the metadata object + * (e.g. [['openid_provider', 'display_name'], ['federation_entity', 'display_name']]) * @param 'asc'|'desc' $direction * @return array> Sorted copy */ - public function sortByMetadataClaim( + public function sortByMetadataClaims( array $entities, - array $claimPath, + array $claimPaths, string $direction = 'asc', ): array { if ($entities === []) { return []; } - uasort($entities, function (array $a, array $b) use ($claimPath, $direction): int { + uasort($entities, function (array $a, array $b) use ($claimPaths, $direction): int { $metadataA = $a[ClaimsEnum::Metadata->value] ?? []; $metadataA = is_array($metadataA) ? $metadataA : []; $metadataB = $b[ClaimsEnum::Metadata->value] ?? []; $metadataB = is_array($metadataB) ? $metadataB : []; - $valA = $this->helpers->arr()->getNestedValue($metadataA, ...$claimPath); - $valB = $this->helpers->arr()->getNestedValue($metadataB, ...$claimPath); + foreach ($claimPaths as $claimPath) { + try { + $valA = $this->helpers->arr()->getNestedValue($metadataA, ...$claimPath); + } catch (OpenIdException $e) { + // If the claim path doesn't exist, treat it as null + $valA = null; + } + try { + $valB = $this->helpers->arr()->getNestedValue($metadataB, ...$claimPath); + } catch (OpenIdException $e) { + // If the claim path doesn't exist, treat it as null + $valB = null; + } - // Treat nulls or non-strings as empty strings for comparison - $strA = is_string($valA) ? $valA : ''; - $strB = is_string($valB) ? $valB : ''; + // Treat nulls or non-strings as empty strings for comparison + $strA = is_string($valA) ? $valA : ''; + $strB = is_string($valB) ? $valB : ''; - $cmp = strcasecmp($strA, $strB); + $cmp = strcasecmp($strA, $strB); - return $direction === 'desc' ? -$cmp : $cmp; + if ($cmp !== 0) { + return $direction === 'desc' ? -$cmp : $cmp; + } + } + + return 0; }); return $entities; diff --git a/src/Federation/Factories/EntityCollectionFactory.php b/src/Federation/Factories/EntityCollectionFactory.php new file mode 100644 index 0000000..a1847c4 --- /dev/null +++ b/src/Federation/Factories/EntityCollectionFactory.php @@ -0,0 +1,35 @@ +> $entities Keyed by entity ID, + * value is JWT payload + */ + public function build(array $entities): EntityCollection + { + return new EntityCollection( + $this->entityCollectionFilter, + $this->entityCollectionSorter, + $this->entityCollectionPaginator, + $entities, + ); + } +} diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index 0a390b9..bf84f8b 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; +use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory; use Throwable; class FederationDiscovery @@ -16,6 +17,7 @@ public function __construct( protected readonly SubordinateListingFetcher $subordinateListingFetcher, protected readonly EntityCollectionStoreInterface $entityCollectionStore, protected readonly DateIntervalDecorator $maxCacheDurationDecorator, + protected readonly EntityCollectionFactory $entityCollectionFactory, protected readonly ?LoggerInterface $logger = null, protected readonly int $maxDepth = 10, ) { @@ -31,13 +33,12 @@ public function __construct( * SubordinateListingFetcher * @param bool $forceRefresh If true, ignore stored entities and * re-traverse the federation - * @return array> */ public function discover( string $trustAnchorId, array $filters = [], bool $forceRefresh = false, - ): array { + ): EntityCollection { if (!$forceRefresh) { $cachedEntities = $this->entityCollectionStore->get($trustAnchorId); if (is_array($cachedEntities)) { @@ -45,7 +46,7 @@ public function discover( 'Returning discovered entities from entity collection store.', ['trustAnchorId' => $trustAnchorId], ); - return $cachedEntities; + return $this->entityCollectionFactory->build($cachedEntities); } } @@ -67,6 +68,8 @@ public function discover( $taConfig->getExpirationTime(), ); + ksort($discoveredEntities); + $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); $this->entityCollectionStore->storeLastUpdated($trustAnchorId, time(), $ttl); @@ -81,7 +84,7 @@ public function discover( ]); } - return $discoveredEntities; + return $this->entityCollectionFactory->build($discoveredEntities); } @@ -97,7 +100,7 @@ public function discoverEntityIds( array $filters = [], bool $forceRefresh = false, ): array { - return array_keys($this->discover($trustAnchorId, $filters, $forceRefresh)); + return array_keys($this->discover($trustAnchorId, $filters, $forceRefresh)->getEntities()); } diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index a6e2db3..e6857ca 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -87,18 +87,19 @@ public function withMultiValueParams(string $url, array $params): string return $this->prepareUri($parsedUri, $newQueryString); } + /** - * @param false|array|int|string|null $parsedUri + * @param array $parsedUri * @param string $newQueryString * @return string */ protected function prepareUri(false|array|int|string|null $parsedUri, string $newQueryString): string { return (isset($parsedUri['scheme']) ? $parsedUri['scheme'] . '://' : '') . - ($parsedUri['host'] ?? '') . - (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . - ($parsedUri['path'] ?? '') . - '?' . $newQueryString . - (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); + ($parsedUri['host'] ?? '') . + (isset($parsedUri['port']) ? ':' . $parsedUri['port'] : '') . + ($parsedUri['path'] ?? '') . + '?' . $newQueryString . + (isset($parsedUri['fragment']) ? '#' . $parsedUri['fragment'] : ''); } } From d7032867d3dcf7a9e992abd4d6ffdfb17772cdd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Thu, 30 Apr 2026 20:27:52 +0200 Subject: [PATCH 12/16] WIP --- src/Federation/EntityCollection.php | 4 ++-- .../EntityCollectionResponseFactory.php | 2 +- .../EntityCollectionSorter.php | 19 ++++++------------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index 578a262..859dc6a 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -62,9 +62,9 @@ public function filter(array $criteria): static * @param 'asc'|'desc' $sortOrder * @return $this */ - public function sortByMetadataClaims(array $claimPaths, string $sortOrder): static + public function sort(array $claimPaths, string $sortOrder): static { - $this->entities = $this->entityCollectionSorter->sortByMetadataClaims( + $this->entities = $this->entityCollectionSorter->sort( $this->entities, $claimPaths, $sortOrder, diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php index 714458f..3be8cf8 100644 --- a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -67,7 +67,7 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC if ($claimPaths !== []) { /** @var non-empty-array $claimPaths */ - $collection->sortByMetadataClaims( + $collection->sort( $claimPaths, (string)($requestParams['sort_dir'] ?? 'asc'), ); diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php index ade0790..8e8c46e 100644 --- a/src/Federation/EntityCollection/EntityCollectionSorter.php +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -4,7 +4,6 @@ namespace SimpleSAML\OpenID\Federation\EntityCollection; -use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Exceptions\OpenIdException; use SimpleSAML\OpenID\Helpers; @@ -17,16 +16,16 @@ public function __construct( /** - * Sort entities by one or more claims nested inside their metadata. + * Sort entities by one or more claims nested inside their payload. * * @param array> $entities Keyed by entity ID * @param non-empty-array $claimPaths Array of - * nested claim paths within the metadata object - * (e.g. [['openid_provider', 'display_name'], ['federation_entity', 'display_name']]) + * nested claim paths within the entity payload object + * (e.g. [['metadata', 'openid_provider', 'display_name'], ['metadata', 'federation_entity', 'display_name']]) * @param 'asc'|'desc' $direction * @return array> Sorted copy */ - public function sortByMetadataClaims( + public function sort( array $entities, array $claimPaths, string $direction = 'asc', @@ -36,21 +35,15 @@ public function sortByMetadataClaims( } uasort($entities, function (array $a, array $b) use ($claimPaths, $direction): int { - $metadataA = $a[ClaimsEnum::Metadata->value] ?? []; - $metadataA = is_array($metadataA) ? $metadataA : []; - - $metadataB = $b[ClaimsEnum::Metadata->value] ?? []; - $metadataB = is_array($metadataB) ? $metadataB : []; - foreach ($claimPaths as $claimPath) { try { - $valA = $this->helpers->arr()->getNestedValue($metadataA, ...$claimPath); + $valA = $this->helpers->arr()->getNestedValue($a, ...$claimPath); } catch (OpenIdException $e) { // If the claim path doesn't exist, treat it as null $valA = null; } try { - $valB = $this->helpers->arr()->getNestedValue($metadataB, ...$claimPath); + $valB = $this->helpers->arr()->getNestedValue($b, ...$claimPath); } catch (OpenIdException $e) { // If the claim path doesn't exist, treat it as null $valB = null; From fb35c92a5c04bd2b5601c777085c4ef0645b039a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 1 May 2026 16:59:15 +0200 Subject: [PATCH 13/16] Add last updated --- src/Federation/EntityCollection.php | 1 + .../EntityCollection/EntityCollectionFetcher.php | 2 +- src/Federation/Factories/EntityCollectionFactory.php | 3 ++- src/Federation/FederationDiscovery.php | 11 ++++++++--- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index 859dc6a..049b281 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -20,6 +20,7 @@ public function __construct( protected readonly EntityCollectionPaginator $entityCollectionPaginator, protected array $entities, protected ?string $nextPageToken = null, + protected ?int $lastUpdated = null, ) { } diff --git a/src/Federation/EntityCollection/EntityCollectionFetcher.php b/src/Federation/EntityCollection/EntityCollectionFetcher.php index 260aa44..af53965 100644 --- a/src/Federation/EntityCollection/EntityCollectionFetcher.php +++ b/src/Federation/EntityCollection/EntityCollectionFetcher.php @@ -27,7 +27,7 @@ public function __construct( * @param non-empty-string $endpointUri * @param array{ * entity_type?: string[], - * trust_mark_type?: string, + * trust_mark_type?: string[], * query?: string, * trust_anchor?: string, * entity_claims?: string[], diff --git a/src/Federation/Factories/EntityCollectionFactory.php b/src/Federation/Factories/EntityCollectionFactory.php index a1847c4..9596124 100644 --- a/src/Federation/Factories/EntityCollectionFactory.php +++ b/src/Federation/Factories/EntityCollectionFactory.php @@ -23,13 +23,14 @@ public function __construct( * @param array> $entities Keyed by entity ID, * value is JWT payload */ - public function build(array $entities): EntityCollection + public function build(array $entities, ?int $lastUpdated): EntityCollection { return new EntityCollection( $this->entityCollectionFilter, $this->entityCollectionSorter, $this->entityCollectionPaginator, $entities, + $lastUpdated, ); } } diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index bf84f8b..557bcec 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -46,7 +46,10 @@ public function discover( 'Returning discovered entities from entity collection store.', ['trustAnchorId' => $trustAnchorId], ); - return $this->entityCollectionFactory->build($cachedEntities); + return $this->entityCollectionFactory->build( + $cachedEntities, + $this->entityCollectionStore->getLastUpdated($trustAnchorId), + ); } } @@ -56,6 +59,7 @@ public function discover( ); $discoveredEntities = []; + $lastUpdated = null; try { // Step 1: Fetch TA config $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); @@ -71,7 +75,8 @@ public function discover( ksort($discoveredEntities); $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); - $this->entityCollectionStore->storeLastUpdated($trustAnchorId, time(), $ttl); + $lastUpdated = time(); + $this->entityCollectionStore->storeLastUpdated($trustAnchorId, $lastUpdated, $ttl); $this->logger?->info('Federation discovery completed.', [ 'trustAnchorId' => $trustAnchorId, @@ -84,7 +89,7 @@ public function discover( ]); } - return $this->entityCollectionFactory->build($discoveredEntities); + return $this->entityCollectionFactory->build($discoveredEntities, $lastUpdated); } From 2b328315a9a2b53489d268c78aae106a1354bd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 1 May 2026 18:47:02 +0200 Subject: [PATCH 14/16] WIP --- docs/3-federation.md | 2 +- docs/5-federation-discovery.md | 470 ++++++------------ src/Federation.php | 30 +- src/Federation/EntityCollection.php | 55 ++ .../EntityCollectionEntry.php | 52 -- .../EntityCollectionFetcher.php | 102 ---- .../EntityCollectionResponse.php | 44 -- .../EntityCollectionResponseFactory.php | 141 ------ .../EntityCollectionSorter.php | 5 +- .../Factories/EntityCollectionFactory.php | 8 +- src/Federation/FederationDiscovery.php | 122 +++++ src/Helpers/Url.php | 2 - 12 files changed, 341 insertions(+), 692 deletions(-) delete mode 100644 src/Federation/EntityCollection/EntityCollectionEntry.php delete mode 100644 src/Federation/EntityCollection/EntityCollectionFetcher.php delete mode 100644 src/Federation/EntityCollection/EntityCollectionResponse.php delete mode 100644 src/Federation/EntityCollection/EntityCollectionResponseFactory.php diff --git a/docs/3-federation.md b/docs/3-federation.md index cd7d825..51531db 100644 --- a/docs/3-federation.md +++ b/docs/3-federation.md @@ -1,4 +1,4 @@ -# OpenID Federation Tools (draft 47) +# OpenID Federation Tools To use it, create an instance of the class `\SimpleSAML\OpenID\Federation`. diff --git a/docs/5-federation-discovery.md b/docs/5-federation-discovery.md index 13f2e0e..20f248a 100644 --- a/docs/5-federation-discovery.md +++ b/docs/5-federation-discovery.md @@ -1,21 +1,19 @@ # Federation Discovery and Entity Collection -This library provides tools for discovering entities within an OpenID Federation -and for working with the Entity Collection Endpoint. The functionality is split -into two main areas: +This library provides a high-performance, specification-compliant toolkit for discovering entities within an OpenID Federation and interacting with Entity Collection Endpoints. -1. **Federation Discovery** — Top-down traversal of a federation hierarchy to - collect all entity IDs. -2. **Entity Collection** — Client-side fetching from a remote - `federation_collection_endpoint`, and server-side building blocks (filtering, - sorting, pagination) for implementing your own collection endpoint. +The functionality is split into two main operational modes: -All components are accessible through the `\SimpleSAML\OpenID\Federation` facade. +1. **Federation Discovery** — A top-down, recursive traversal of a federation hierarchy starting from a Trust Anchor. +2. **Entity Collection** — A specialized protocol for optimized bulk-fetching of entities, featuring support for server-side filtering, sorting, and cursor-based pagination. -## Setup +All components are integrated and accessible through the `\SimpleSAML\OpenID\Federation` facade. -Federation discovery extends the standard `Federation` instantiation with two -additional constructor parameters: +--- + +## Setup and Configuration + +To enable federation discovery, initialize the `Federation` facade with a cache and (optionally) a logger. ```php [!NOTE] +> The store caches the **JWT payload arrays** of discovered entities. Actual JWS signatures and original JWT strings are managed by the `EntityStatementFetcher` which handles its own caching and validation logic. -```php -interface EntityCollectionStoreInterface -{ - /** - * Persist discovered entities for a given Trust Anchor. - */ - public function store(string $trustAnchorId, array $entities, int $ttl): void; - - /** - * Retrieve previously discovered entities. - * Return null when not found or expired. - */ - public function get(string $trustAnchorId): ?array; - - /** - * Remove stored entities (for force re-discovery). - */ - public function clear(string $trustAnchorId): void; -} -``` - -> **Note**: The store tracks the JWT payload arrays per Trust Anchor. -> Entity Configurations are fetched dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()` -> during the traversal process, which handles JWS-level caching and respects expiry. +--- -## Federation Discovery +## Federation Discovery (Top-Down) -Federation Discovery performs a top-down traversal of the federation hierarchy. -Starting from a Trust Anchor, it follows `federation_list_endpoint` links on -each entity to collect all subordinate entity IDs recursively. +Federation Discovery performs a recursive traversal of the hierarchy. It starts at the Trust Anchor and follows `federation_list_endpoint` links to discover all subordinates. ### Discovering Entities @@ -96,326 +72,184 @@ each entity to collect all subordinate entity IDs recursively. $trustAnchorId = 'https://trust-anchor.example.org/'; try { - // Discover all entities (ID -> payload map) in the federation. - $entities = $federationTools->federationDiscovery() - ->discover($trustAnchorId); - - // $entities is an array keyed by entity ID, where values are JWT payload arrays: - // [ - // 'https://trust-anchor.example.org/' => ['iss' => '...', 'metadata' => [...]], - // ... - // ] + // Traverse the federation and return an EntityCollection object. + $collection = $federationTools->federationDiscovery()->discover($trustAnchorId); + + // Get the raw map of Entity ID => Payload + $entities = $collection->getEntities(); + + // Convenience: Get just the discovered entity IDs + $ids = $federationTools->federationDiscovery()->discoverEntityIds($trustAnchorId); } catch (\Throwable $exception) { $logger->error('Federation discovery failed: ' . $exception->getMessage()); } ``` -The discovery algorithm: - -1. Fetches the Entity Configuration of the Trust Anchor. -2. Extracts the `federation_list_endpoint` from its metadata. -3. Calls the subordinate listing endpoint to get immediate subordinate IDs. -4. For each subordinate, fetches its Entity Configuration and, if it has its own - `federation_list_endpoint`, recurses (up to `maxDiscoveryDepth`). -5. Deduplicates all collected entities. -6. Persists the entity payloads in the store with a TTL based on the Trust Anchor's - expiry and the configured `maxCacheDuration`. +### Discovery Logic & Loop Protection -If you only need the list of entity IDs without their payloads, use the convenience method: - -```php -$entityIds = $federationTools->federationDiscovery() - ->discoverEntityIds($trustAnchorId); -``` +1. **Trust Anchor Config**: Fetches and validates the TA's Entity Configuration. +2. **Subordinate Listing**: Fetches the `federation_list_endpoint`. If filters are provided, they are passed as query parameters to this endpoint. +3. **Recursion**: For each discovered subordinate, it fetches its configuration and repeats the process. +4. **Loop Protection**: The algorithm tracks visited IDs to prevent infinite loops and is limited by `maxDiscoveryDepth`. +5. **Deduplication**: Entities appearing in multiple branches are only stored once. ### Applying Filters During Discovery -You can pass filter parameters (e.g. `entity_type`) to the subordinate listing -endpoint: +You can pass filters (like `entity_type`) directly to the discovery process. These are passed to the remote `federation_list_endpoint` to optimize the traversal: ```php -$entities = $federationTools->federationDiscovery() - ->discover( - $trustAnchorId, - filters: ['entity_type' => 'openid_relying_party'], - ); +$collection = $federationTools->federationDiscovery() + ->discover($trustAnchorId, filters: ['entity_type' => 'openid_provider']); ``` -### Periodic Refresh (Cron / Background Jobs) +### Performance: Scheduled Refresh -Use the `forceRefresh` parameter to clear the stored entities and -re-traverse the federation. This is the intended pattern for cron or background -refresh jobs: +Discovery is an expensive network-heavy operation. You should run it in a background process (Cron) using `forceRefresh: true` to populate the cache: ```php -// In a scheduled task / cron job: +// In a background job: $federationTools->federationDiscovery() ->discover($trustAnchorId, forceRefresh: true); -``` -When `forceRefresh` is `true`: +// In your web application (uses cache): +$collection = $federationTools->federationDiscovery()->discover($trustAnchorId); +``` -- The full federation traversal is re-executed. -- The new entity payload map is stored. -- Entity Configurations that haven't expired in the JWS cache are served from - cache; only stale or new ones trigger network requests. +--- ## Entity Collection Client -The Entity Collection Client fetches from a remote -`federation_collection_endpoint` and deserializes the response into typed -objects. - -### Fetching from a Remote Endpoint - -```php -/** @var \SimpleSAML\OpenID\Federation $federationTools */ +The Entity Collection Client allows fetching pre-filtered lists of entities from a remote `federation_collection_endpoint`. This is much more efficient than full traversal if the remote side supports it. -$collectionEndpointUri = 'https://trust-anchor.example.org/federation_collection'; +### Bulk Fetching with Filters -try { - $response = $federationTools->entityCollectionFetcher() - ->fetch($collectionEndpointUri); - - // Iterate over the entries. - foreach ($response->entities as $entry) { - echo $entry->entityId . PHP_EOL; - echo 'Types: ' . implode(', ', $entry->entityTypes) . PHP_EOL; - - if ($entry->uiInfos !== null) { - echo 'Display: ' . ($entry->uiInfos['display_name'] ?? 'N/A') . PHP_EOL; - } - } - - // Check if there are more pages. - if ($response->next !== null) { - // Fetch next page using the cursor. - $nextPage = $federationTools->entityCollectionFetcher() - ->fetch($collectionEndpointUri, ['from' => $response->next]); - } -} catch (\Throwable $exception) { - $logger->error('Entity collection fetch failed: ' . $exception->getMessage()); -} -``` - -### Applying Filters - -The `fetch()` method accepts filter parameters as defined by the Entity -Collection Endpoint specification: +The client supports all standard OpenID Federation query parameters: ```php -$response = $federationTools->entityCollectionFetcher()->fetch( - $collectionEndpointUri, +$endpoint = 'https://federation.example.org/collection'; + +$collection = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint( + $endpoint, [ - 'entity_type' => ['openid_provider', 'openid_relying_party'], - 'trust_mark_type' => 'https://example.com/trust-mark/member', - 'query' => 'university', - 'limit' => 20, - ], + 'entity_type' => ['openid_provider'], + 'trust_mark_type' => ['https://example.org/marks/certified'], + 'trust_anchor' => 'https://trust-anchor.example.org/', + 'query' => 'university', + 'limit' => 50, + 'entity_claims' => ['display_name', 'contacts'], // Request specific claims + ] ); -``` - -Multi-value parameters (like `entity_type`) are serialized as repeated query -keys (`?entity_type=openid_provider&entity_type=openid_relying_party`) per the -specification. - -### Response Objects - -- **`EntityCollectionResponse`** — Contains the `entities` array, - an optional `next` cursor for pagination, and an optional `lastUpdated` - timestamp. Implements `JsonSerializable`. -- **`EntityCollectionEntry`** — Represents a single entity in the collection. - Contains `entityId`, `entityTypes`, optional `uiInfos`, and optional - `trustMarks`. Implements `JsonSerializable`. - -## Server-Side Building Blocks - -If you want to implement and serve your own `federation_collection_endpoint`, -this library provides building-block components that handle the core logic. You -only need to wire them into your HTTP framework's controller. - -### Overview - -The server-side pipeline follows this order: - -1. **Discover** — Collect entities from the federation. -2. **Filter** — Apply client-requested filters (entity type, trust mark, query). -3. **Sort** — Order by a metadata claim (e.g. `display_name`). -4. **Project** — Select only the requested UI claims. -5. **Paginate** — Slice the result set and produce a cursor. -6. **Serialize** — Return a `JsonSerializable` response. - -### Using EntityCollectionResponseFactory - -The `EntityCollectionResponseFactory` is a convenience orchestrator that wires -all the above steps into a single call: - -```php -/** @var \SimpleSAML\OpenID\Federation $federationTools */ - -$trustAnchorId = 'https://trust-anchor.example.org/'; - -// In your controller, pass the incoming request parameters directly. -$requestParams = $request->getQueryParams(); -$response = $federationTools->entityCollectionResponseFactory() - ->build($trustAnchorId, $requestParams); - -// The response implements JsonSerializable. -return new JsonResponse(json_encode($response)); +foreach ($collection->getEntities() as $id => $payload) { + // Process entity... +} ``` -Supported request parameters: - -| Parameter | Type | Description | -|---|---|---| -| `entity_type` | `string[]` | Filter by entity type keys (e.g. `openid_provider`) | -| `trust_mark_type` | `string` | Filter by Trust Mark type | -| `query` | `string` | Free-text search on entity ID, `display_name`, `organization_name` | -| `trust_anchor` | `string` | Filter by Trust Anchor (via `authority_hints`) | -| `sort_by` | `string` | Dot-separated claim path (e.g. `federation_entity.display_name`) | -| `sort_dir` | `'asc'\|'desc'` | Sort direction, defaults to `asc` | -| `ui_claims` | `string[]` | Claims to include in the `ui_infos` projection | -| `limit` | `int` | Maximum entries per page (default 100) | -| `from` | `string` | Opaque cursor from a previous response's `next` field | +### Client-Side Caching -### Using Individual Components +`discoverFromCollectionEndpoint()` automatically caches the remote response body. If you need fresh data, pass `forceRefresh: true`. -You can also use each building block independently for maximum control. +### Pagination Handling -#### EntityCollectionFilter - -Filters entity configurations by various criteria: +The `EntityCollection` object encapsulates the `next` cursor for seamless pagination: ```php -use SimpleSAML\OpenID\Federation\EntityCollection; - -/** @var \SimpleSAML\OpenID\Federation $federationTools */ - -// Prepare a collection from discovery or any other source. -$entities = $federationTools->federationDiscovery() - ->discover($trustAnchorId); -$collection = new EntityCollection($entities); - -// Filter by entity type and text query. -$filtered = $federationTools->entityCollectionFilter()->filter( - $collection, - [ - 'entity_type' => ['openid_provider'], - 'query' => 'university', - ], -); +$results = []; +$cursor = null; -// $filtered is array> keyed by entity ID. +do { + $page = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint( + $endpoint, + ['limit' => 100, 'from' => $cursor] + ); + + $results = array_merge($results, $page->getEntities()); + $cursor = $page->getNextPageToken(); +} while ($cursor !== null); ``` -#### EntityCollectionSorter +--- -Sorts entities by a metadata claim value: +## Server-Side Implementation -```php -/** @var \SimpleSAML\OpenID\Federation $federationTools */ +If you are implementing your own `federation_collection_endpoint`, the library provides high-level building blocks to handle filtering, sorting, and pagination. -// Sort by display_name under the federation_entity metadata. -$sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim( - $filtered, // array> - ['federation_entity', 'display_name'], - 'asc', -); +### The Pipeline Pattern -// Sort by organization_name under the openid_provider metadata. -$sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim( - $filtered, - ['openid_provider', 'organization_name'], - 'desc', -); -``` - -Entities missing the specified claim are placed at the end of the result set. - -#### EntityCollectionPaginator - -Slices a pre-sorted result set into a page with an opaque cursor: +The recommended implementation follows this pipeline: **Discover → Filter → Sort → Paginate → Serialize**. ```php -/** @var \SimpleSAML\OpenID\Federation $federationTools */ - -$paginated = $federationTools->entityCollectionPaginator()->paginate( - $sorted, // Pre-sorted array|EntityCollectionEntry> - 20, // Limit (page size) - null, // Cursor from a previous response's 'next' value, or null -); - -$pageEntities = $paginated['entities']; // array -$nextCursor = $paginated['next']; // ?string — null when on the last page +public function __invoke(ServerRequestInterface $request): ResponseInterface +{ + $params = $request->getQueryParams(); + + // 1. Load entities from the Federation traversal cache + $collection = $this->federationTools->federationDiscovery()->discover($this->trustAnchorId); + + // 2. Filter (Standard OpenID Federation criteria) + // Supports 'entity_type' (OR), 'trust_mark_type' (AND), and 'query' (Search) + $collection->filter($params); + + // 3. Sort (By nested metadata claims) + if (isset($params['sort_by'])) { + $path = explode('.', $params['sort_by']); // e.g. "federation_entity.display_name" + $collection->sort([$path], $params['sort_dir'] ?? 'asc'); + } + + // 4. Paginate (Using opaque cursors) + $collection->paginate( + limit: (int) ($params['limit'] ?? 100), + from: $params['from'] ?? null + ); + + // 5. Serialize to spec-compliant array + return new JsonResponse($collection->toCollectionEndpointResponseArray()); +} ``` -The `next` cursor is an opaque base64url-encoded pointer. Pass it as the `from` -parameter in the next request to continue pagination. +### Filtering Technical Details -## Full Server-Side Example +| Criteria | Behavior | Fields Checked | +| :--- | :--- | :--- | +| `entity_type` | **OR** (Any match) | Metadata keys | +| `trust_mark_type` | **AND** (All must match) | `trust_marks[].id` | +| `query` | **Case-Insensitive** | `sub`, `display_name`, `organization_name` | -Here is a complete example of wiring the building blocks into a controller -action: +### Sorting Technical Details -```php -federationTools - ->entityCollectionResponseFactory() - ->build($this->trustAnchorId, $request->getQueryParams()); - - // EntityCollectionResponse implements JsonSerializable. - return json_encode($response, JSON_THROW_ON_ERROR); - } -} +```php +$collection->sort([ + ['metadata', 'openid_provider', 'display_name'], // Primary (Metadata) + ['metadata', 'federation_entity', 'display_name'], // Fallback 1 (Metadata) + ['sub'] // Fallback 2 (Entity ID root claim) +], 'asc'); ``` -Example request: +--- -``` -GET /federation_collection?entity_type=openid_provider&query=university&sort_by=federation_entity.display_name&limit=10 -``` +## Serialized Response Format -Example response: +The `toCollectionEndpointResponseArray()` method produces a structure compatible with the OpenID Federation specification: ```json { - "entities": [ - { - "entity_id": "https://idp.university-a.example.org/", - "entity_types": ["openid_provider"], - "ui_infos": { - "display_name": "University A Identity Provider" - } - }, - { - "entity_id": "https://idp.university-b.example.org/", - "entity_types": ["openid_provider"], - "ui_infos": { - "display_name": "University B Identity Provider" - } + "entities": [ + { + "entity_id": "https://idp.example.org/", + "entity_types": ["openid_provider"], + "ui_infos": { + "openid_provider": { + "display_name": "Example IDP" } - ], - "next": "aHR0cHM6Ly9pZHAudW5pdmVyc2l0eS1iLmV4YW1wbGUub3JnLw", - "last_updated": 1745410000 + }, + "trust_marks": [ + { "id": "https://example.org/marks/certified", "trust_mark": "..." } + ] + } + ], + "next": "aHR0cHM6Ly9pZHAuZXhhbXBsZS5vcmcv", + "last_updated": 1745410000 } ``` diff --git a/src/Federation.php b/src/Federation.php index b877737..cab70b0 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -20,10 +20,8 @@ use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerDecoratorFactory; use SimpleSAML\OpenID\Federation\EntityCollection\CacheEntityCollectionStore; -use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFetcher; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; -use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionResponseFactory; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use SimpleSAML\OpenID\Federation\EntityCollection\InMemoryEntityCollectionStore; @@ -77,16 +75,12 @@ class Federation protected ?FederationDiscovery $federationDiscovery = null; - protected ?EntityCollectionFetcher $entityCollectionFetcher = null; - protected ?EntityCollectionFilter $entityCollectionFilter = null; protected ?EntityCollectionSorter $entityCollectionSorter = null; protected ?EntityCollectionPaginator $entityCollectionPaginator = null; - protected ?EntityCollectionResponseFactory $entityCollectionBuilder = null; - protected ?EntityStatementFetcher $entityStatementFetcher = null; protected ?MetadataPolicyResolver $metadataPolicyResolver = null; @@ -399,6 +393,8 @@ public function federationDiscovery(): FederationDiscovery $this->entityCollectionStore(), $this->maxCacheDurationDecorator(), $this->entityCollectionFactory(), + $this->artifactFetcher(), + $this->helpers(), $this->logger, $this->maxDiscoveryDepth, ); @@ -408,16 +404,6 @@ public function federationDiscovery(): FederationDiscovery } - public function entityCollectionFetcher(): EntityCollectionFetcher - { - return $this->entityCollectionFetcher ??= new EntityCollectionFetcher( - $this->artifactFetcher(), - $this->helpers(), - $this->logger, - ); - } - - public function entityCollectionFilter(): EntityCollectionFilter { return $this->entityCollectionFilter ??= new EntityCollectionFilter($this->helpers()); @@ -438,18 +424,6 @@ public function entityCollectionPaginator(): EntityCollectionPaginator } - public function entityCollectionResponseFactory(): EntityCollectionResponseFactory - { - return $this->entityCollectionBuilder ??= new EntityCollectionResponseFactory( - $this->federationDiscovery(), - $this->entityCollectionFilter(), - $this->entityCollectionSorter(), - $this->entityCollectionPaginator(), - $this->entityCollectionStore(), - ); - } - - public function helpers(): Helpers { return $this->helpers ??= new Helpers(); diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php index 049b281..2e30579 100644 --- a/src/Federation/EntityCollection.php +++ b/src/Federation/EntityCollection.php @@ -4,6 +4,7 @@ namespace SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; @@ -34,6 +35,60 @@ public function getEntities(): array } + public function getLastUpdated(): ?int + { + return $this->lastUpdated; + } + + + /** + * @return array{ + * entities: array>, + * next?: string, + * last_updated?: int + * } + */ + public function toCollectionEndpointResponseArray(): array + { + $entities = []; + foreach ($this->entities as $payload) { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; + if (!is_array($metadata)) { + $metadata = []; + } + + $entry = [ + ClaimsEnum::EntityId->value => $payload[ClaimsEnum::Sub->value] ?? '', + ClaimsEnum::EntityTypes->value => array_keys($metadata), + ]; + + if ($metadata !== []) { + $entry[ClaimsEnum::UiInfos->value] = $metadata; + } + + if (isset($payload[ClaimsEnum::TrustMarks->value])) { + $entry[ClaimsEnum::TrustMarks->value] = $payload[ClaimsEnum::TrustMarks->value]; + } + + $entities[] = $entry; + } + + $data = [ + ClaimsEnum::Entities->value => $entities, + ]; + + if (!is_null($this->nextPageToken)) { + $data[ClaimsEnum::Next->value] = $this->nextPageToken; + } + + if (!is_null($this->lastUpdated)) { + $data[ClaimsEnum::LastUpdated->value] = $this->lastUpdated; + } + + return $data; + } + + /** * Apply filters to the collection. Supported criteria keys: * - entity_type: array of entity types to include diff --git a/src/Federation/EntityCollection/EntityCollectionEntry.php b/src/Federation/EntityCollection/EntityCollectionEntry.php deleted file mode 100644 index 099875e..0000000 --- a/src/Federation/EntityCollection/EntityCollectionEntry.php +++ /dev/null @@ -1,52 +0,0 @@ -|null $uiInfos Logo, display name, etc. - * @param array>|null $trustMarks - */ - public function __construct( - public readonly string $entityId, - public readonly array $entityTypes, - public readonly ?array $uiInfos = null, - public readonly ?array $trustMarks = null, - ) { - } - - - /** - * @return array{ - * entity_id: non-empty-string, - * entity_types: non-empty-string[], - * ui_infos?: array, - * trust_marks?: array> - * } - */ - public function jsonSerialize(): array - { - $data = [ - ClaimsEnum::EntityId->value => $this->entityId, - ClaimsEnum::EntityTypes->value => $this->entityTypes, - ]; - - if (!is_null($this->uiInfos)) { - $data[ClaimsEnum::UiInfos->value] = $this->uiInfos; - } - - if (!is_null($this->trustMarks)) { - $data[ClaimsEnum::TrustMarks->value] = $this->trustMarks; - } - - return $data; - } -} diff --git a/src/Federation/EntityCollection/EntityCollectionFetcher.php b/src/Federation/EntityCollection/EntityCollectionFetcher.php deleted file mode 100644 index af53965..0000000 --- a/src/Federation/EntityCollection/EntityCollectionFetcher.php +++ /dev/null @@ -1,102 +0,0 @@ -helpers->url()->withMultiValueParams($endpointUri, $filters); - - $this->logger?->debug('Fetching entity collection.', ['uri' => $uri, 'filters' => $filters]); - - try { - $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); - - $decoded = $this->helpers->json()->decode($responseBody); - - if ( - !is_array($decoded) || - !isset($decoded[ClaimsEnum::Entities->value]) || - !is_array($decoded[ClaimsEnum::Entities->value]) - ) { - throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); - } - - $entries = []; - foreach ($decoded[ClaimsEnum::Entities->value] as $entryData) { - if (!is_array($entryData)) { - continue; - } - - /** @var array|null $uiInfo */ - $uiInfo = is_array($entryData[ClaimsEnum::UiInfos->value] ?? null) ? - $entryData[ClaimsEnum::UiInfos->value] : - null; - /** @var array>|null $trustMarks */ - $trustMarks = is_array($entryData[ClaimsEnum::TrustMarks->value] ?? null) - ? $entryData[ClaimsEnum::TrustMarks->value] - : null; - - $entries[] = new EntityCollectionEntry( - $this->helpers->type()->ensureNonEmptyString($entryData[ClaimsEnum::Id->value] ?? null), - $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings( - $entryData[ClaimsEnum::EntityTypes->value] ?? [], - ClaimsEnum::EntityTypes->value, - ), - $uiInfo, - $trustMarks, - ); - } - - $next = is_string($next = $decoded[ClaimsEnum::Next->value] ?? null) ? $next : null; - $lastUpdated = is_numeric($lastUpdated = $decoded[ClaimsEnum::LastUpdated->value] ?? null) ? - $this->helpers->type()->ensureInt($lastUpdated) : - null; - - return new EntityCollectionResponse( - $entries, - $next, - $lastUpdated, - ); - } catch (Throwable $throwable) { - $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); - $this->logger?->error($message); - throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); - } - } -} diff --git a/src/Federation/EntityCollection/EntityCollectionResponse.php b/src/Federation/EntityCollection/EntityCollectionResponse.php deleted file mode 100644 index c281238..0000000 --- a/src/Federation/EntityCollection/EntityCollectionResponse.php +++ /dev/null @@ -1,44 +0,0 @@ -value => $this->entities, - ]; - - if (!is_null($this->next)) { - $data[ClaimsEnum::Next->value] = $this->next; - } - - if (!is_null($this->lastUpdated)) { - $data[ClaimsEnum::LastUpdated->value] = $this->lastUpdated; - } - - return $data; - } -} diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php deleted file mode 100644 index 3be8cf8..0000000 --- a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php +++ /dev/null @@ -1,141 +0,0 @@ -federationDiscovery->discover($trustAnchorId); - $collection = new EntityCollection( - $this->filter, - $this->sorter, - $this->paginator, - $entities->getEntities(), - ); - - // 2. Filter - $collection->filter($requestParams); - - // 3. Sort - if (isset($requestParams['sort_by'])) { - $sortByParams = is_array($requestParams['sort_by']) - ? $requestParams['sort_by'] - : [$requestParams['sort_by']]; - - $claimPaths = []; - foreach ($sortByParams as $sortBy) { - if (!is_string($sortBy)) { - continue; - } - $claimPaths[] = explode('.', $sortBy); - } - - if ($claimPaths !== []) { - /** @var non-empty-array $claimPaths */ - $collection->sort( - $claimPaths, - (string)($requestParams['sort_dir'] ?? 'asc'), - ); - } - } - - // 4. Claims sub-selection (Projection) - $entries = []; - $uiClaims = $requestParams['ui_claims'] ?? null; - - foreach ($collection->getEntities() as $id => $payload) { - $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; - if (!is_array($metadata)) { - $metadata = []; - } - - /** @var non-empty-string[] $entityTypes */ - $entityTypes = array_keys($metadata); - - // ui_info projection - $uiInfo = null; - if (is_array($uiClaims) && $uiClaims !== []) { - $uiInfo = []; - foreach ($metadata as $typePayload) { - if (!is_array($typePayload)) { - continue; - } - - foreach ($uiClaims as $claim) { - if (isset($typePayload[$claim])) { - $uiInfo[$claim] = $typePayload[$claim]; - } - } - } - } - - // trust_marks projection is handled by getting them from statement - $trustMarks = null; - $marks = $payload[ClaimsEnum::TrustMarks->value] ?? null; - if (is_array($marks)) { - /** @var array> $marks */ - $trustMarks = $marks; - } - - // If entity_claims is provided, we might want to filter the metadata itself, - // but the EntityCollectionEntry DTO currently separates ui_info. - // For now, project into the Entry VO. - /** @var non-empty-string $id */ - $entries[$id] = new EntityCollectionEntry( - $id, - $entityTypes, - $uiInfo, - $trustMarks, - ); - } - - // 5. Paginate - $limit = isset($requestParams['limit']) ? (int)$requestParams['limit'] : 100; - $limit = max(1, $limit); - - $from = $requestParams['from'] ?? null; - - $paginated = $this->paginator->paginate($entries, $limit, $from); - - return new EntityCollectionResponse( - entities: array_values($paginated['entities']), - next: $paginated['next'], - lastUpdated: $this->entityCollectionStore->getLastUpdated($trustAnchorId) ?? time(), - ); - } -} diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php index 8e8c46e..70f9881 100644 --- a/src/Federation/EntityCollection/EntityCollectionSorter.php +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -38,13 +38,14 @@ public function sort( foreach ($claimPaths as $claimPath) { try { $valA = $this->helpers->arr()->getNestedValue($a, ...$claimPath); - } catch (OpenIdException $e) { + } catch (OpenIdException) { // If the claim path doesn't exist, treat it as null $valA = null; } + try { $valB = $this->helpers->arr()->getNestedValue($b, ...$claimPath); - } catch (OpenIdException $e) { + } catch (OpenIdException) { // If the claim path doesn't exist, treat it as null $valB = null; } diff --git a/src/Federation/Factories/EntityCollectionFactory.php b/src/Federation/Factories/EntityCollectionFactory.php index 9596124..8ef0f88 100644 --- a/src/Federation/Factories/EntityCollectionFactory.php +++ b/src/Federation/Factories/EntityCollectionFactory.php @@ -23,13 +23,17 @@ public function __construct( * @param array> $entities Keyed by entity ID, * value is JWT payload */ - public function build(array $entities, ?int $lastUpdated): EntityCollection - { + public function build( + array $entities, + ?int $lastUpdated, + ?string $nextPageToken = null, + ): EntityCollection { return new EntityCollection( $this->entityCollectionFilter, $this->entityCollectionSorter, $this->entityCollectionPaginator, $entities, + $nextPageToken, $lastUpdated, ); } diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index 557bcec..d3c91c8 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -5,9 +5,13 @@ namespace SimpleSAML\OpenID\Federation; use Psr\Log\LoggerInterface; +use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; +use SimpleSAML\OpenID\Exceptions\EntityDiscoveryException; use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory; +use SimpleSAML\OpenID\Helpers; +use SimpleSAML\OpenID\Utils\ArtifactFetcher; use Throwable; class FederationDiscovery @@ -18,6 +22,8 @@ public function __construct( protected readonly EntityCollectionStoreInterface $entityCollectionStore, protected readonly DateIntervalDecorator $maxCacheDurationDecorator, protected readonly EntityCollectionFactory $entityCollectionFactory, + protected readonly ArtifactFetcher $artifactFetcher, + protected readonly Helpers $helpers, protected readonly ?LoggerInterface $logger = null, protected readonly int $maxDepth = 10, ) { @@ -93,6 +99,122 @@ public function discover( } + /** + * Fetch an entity collection from a remote endpoint. + * + * @param non-empty-string $endpointUri + * @param array{ + * entity_type?: string[], + * trust_mark_type?: string[], + * query?: string, + * trust_anchor?: string, + * entity_claims?: string[], + * ui_claims?: string[], + * limit?: positive-int, + * from?: string, + * } $filters + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + public function discoverFromCollectionEndpoint( + string $endpointUri, + array $filters = [], + bool $forceRefresh = false, + ): EntityCollection { + $uri = $this->helpers->url()->withMultiValueParams($endpointUri, $filters); + + if (!$forceRefresh) { + $this->logger?->debug('Checking for cached entity collection.', ['uri' => $uri]); + $cached = $this->artifactFetcher->fromCacheAsString($uri); + if ($cached !== null) { + $this->logger?->debug('Returning cached entity collection.', ['uri' => $uri]); + return $this->buildEntityCollectionFromResponse($cached); + } + + $this->logger?->debug('No cached entity collection found.', ['uri' => $uri]); + } + + $this->logger?->debug('Fetching entity collection.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + + $collection = $this->buildEntityCollectionFromResponse($responseBody); + + $this->artifactFetcher->cacheIt( + $responseBody, + $this->maxCacheDurationDecorator->getInSeconds(), + $uri, + ); + + $this->logger?->debug('Fetched and cached entity collection.', ['uri' => $uri]); + + return $collection; + } catch (Throwable $throwable) { + $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } + + + private function buildEntityCollectionFromResponse(string $responseBody): EntityCollection + { + $decoded = $this->helpers->json()->decode($responseBody); + + if ( + !is_array($decoded) || + !isset($decoded[ClaimsEnum::Entities->value]) || + !is_array($decoded[ClaimsEnum::Entities->value]) + ) { + throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); + } + + $entities = []; + foreach ($decoded[ClaimsEnum::Entities->value] as $entryData) { + if (!is_array($entryData)) { + continue; + } + + $entityId = $this->helpers->type()->ensureNonEmptyString( + $entryData[ClaimsEnum::EntityId->value] ?? null, + ClaimsEnum::EntityId->value, + ); + + $metadata = []; + $uiInfos = $entryData[ClaimsEnum::UiInfos->value] ?? []; + if (is_array($uiInfos)) { + foreach ($uiInfos as $type => $typePayload) { + if (is_string($type) && is_array($typePayload)) { + $metadata[$type] = $typePayload; + } + } + } + + $payload = [ + ClaimsEnum::Sub->value => $entityId, + ClaimsEnum::Metadata->value => $metadata, + ]; + + if (isset($entryData[ClaimsEnum::TrustMarks->value])) { + $payload[ClaimsEnum::TrustMarks->value] = $entryData[ClaimsEnum::TrustMarks->value]; + } + + $entities[$entityId] = $payload; + } + + $next = is_string($next = $decoded[ClaimsEnum::Next->value] ?? null) ? $next : null; + $lastUpdated = is_numeric($lastUpdated = $decoded[ClaimsEnum::LastUpdated->value] ?? null) ? + $this->helpers->type()->ensureInt($lastUpdated) : + null; + + return $this->entityCollectionFactory->build( + $entities, + $lastUpdated, + $next, + ); + } + + /** * Discover just the entity IDs in the federation. * diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index e6857ca..48d4fa8 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -90,8 +90,6 @@ public function withMultiValueParams(string $url, array $params): string /** * @param array $parsedUri - * @param string $newQueryString - * @return string */ protected function prepareUri(false|array|int|string|null $parsedUri, string $newQueryString): string { From 53802b26e20bb3a620de82090c186ca256768766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 1 May 2026 19:38:29 +0200 Subject: [PATCH 15/16] WIP --- docs/1-openid.md | 1 + ...scovery.md => 3.1-federation-discovery.md} | 85 ++++++--- src/Federation.php | 1 + src/Federation/FederationDiscovery.php | 13 +- src/Federation/SubordinateListingFetcher.php | 47 ++++- .../SubordinateListingFetcherTest.php | 161 ++++++++++++++++++ 6 files changed, 266 insertions(+), 42 deletions(-) rename docs/{5-federation-discovery.md => 3.1-federation-discovery.md} (73%) create mode 100644 tests/src/Federation/SubordinateListingFetcherTest.php diff --git a/docs/1-openid.md b/docs/1-openid.md index 60e9059..2dc65dd 100644 --- a/docs/1-openid.md +++ b/docs/1-openid.md @@ -2,5 +2,6 @@ 1. [Installation](2-installation.md) 2. [OpenID Federation Tools](3-federation.md) +2.1 [Federation Discovery](3.1-federation-discovery.md) 3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md) 4. [Federation Discovery and Entity Collection](5-federation-discovery.md) diff --git a/docs/5-federation-discovery.md b/docs/3.1-federation-discovery.md similarity index 73% rename from docs/5-federation-discovery.md rename to docs/3.1-federation-discovery.md index 20f248a..aaf1944 100644 --- a/docs/5-federation-discovery.md +++ b/docs/3.1-federation-discovery.md @@ -1,19 +1,26 @@ # Federation Discovery and Entity Collection -This library provides a high-performance, specification-compliant toolkit for discovering entities within an OpenID Federation and interacting with Entity Collection Endpoints. +This library provides a high-performance, specification-compliant toolkit for +discovering entities within an OpenID Federation and interacting with Entity +Collection Endpoints. The functionality is split into two main operational modes: -1. **Federation Discovery** — A top-down, recursive traversal of a federation hierarchy starting from a Trust Anchor. -2. **Entity Collection** — A specialized protocol for optimized bulk-fetching of entities, featuring support for server-side filtering, sorting, and cursor-based pagination. +1. **Federation Discovery** — A top-down, recursive traversal of a federation +hierarchy starting from a Trust Anchor. +2. **Entity Collection** — A specialized protocol for optimized bulk-fetching +of entities, featuring support for server-side filtering, sorting, and +cursor-based pagination. -All components are integrated and accessible through the `\SimpleSAML\OpenID\Federation` facade. +All components are integrated and accessible through the +`\SimpleSAML\OpenID\Federation` facade. --- ## Setup and Configuration -To enable federation discovery, initialize the `Federation` facade with a cache and (optionally) a logger. +To enable federation discovery, initialize the `Federation` facade with a +cache and (optionally) a logger. ```php [!NOTE] -> The store caches the **JWT payload arrays** of discovered entities. Actual JWS signatures and original JWT strings are managed by the `EntityStatementFetcher` which handles its own caching and validation logic. +> The store caches the **JWT payload arrays** of discovered entities. Actual +> JWS signatures and original JWT strings are managed by the +> `EntityStatementFetcher` which handles its own caching and validation logic. --- ## Federation Discovery (Top-Down) -Federation Discovery performs a recursive traversal of the hierarchy. It starts at the Trust Anchor and follows `federation_list_endpoint` links to discover all subordinates. +Federation Discovery performs a recursive traversal of the hierarchy. +It starts at the Trust Anchor and follows `federation_list_endpoint` links +to discover all subordinates. ### Discovering Entities @@ -88,14 +100,20 @@ try { ### Discovery Logic & Loop Protection 1. **Trust Anchor Config**: Fetches and validates the TA's Entity Configuration. -2. **Subordinate Listing**: Fetches the `federation_list_endpoint`. If filters are provided, they are passed as query parameters to this endpoint. -3. **Recursion**: For each discovered subordinate, it fetches its configuration and repeats the process. -4. **Loop Protection**: The algorithm tracks visited IDs to prevent infinite loops and is limited by `maxDiscoveryDepth`. -5. **Deduplication**: Entities appearing in multiple branches are only stored once. +2. **Subordinate Listing**: Fetches the `federation_list_endpoint`. +If filters are provided, they are passed as query parameters to this endpoint. +3. **Recursion**: For each discovered subordinate, it fetches its +configuration and repeats the process. +4. **Loop Protection**: The algorithm tracks visited IDs to prevent +infinite loops and is limited by `maxDiscoveryDepth`. +5. **Deduplication**: Entities appearing in multiple branches are only stored +once. ### Applying Filters During Discovery -You can pass filters (like `entity_type`) directly to the discovery process. These are passed to the remote `federation_list_endpoint` to optimize the traversal: +You can pass filters (like `entity_type`) directly to the discovery process. +These are passed to the remote `federation_list_endpoint` to optimize the +traversal: ```php $collection = $federationTools->federationDiscovery() @@ -104,7 +122,8 @@ $collection = $federationTools->federationDiscovery() ### Performance: Scheduled Refresh -Discovery is an expensive network-heavy operation. You should run it in a background process (Cron) using `forceRefresh: true` to populate the cache: +Discovery is an expensive network-heavy operation. You should run it in a +background process (Cron) using `forceRefresh: true` to populate the cache: ```php // In a background job: @@ -119,7 +138,9 @@ $collection = $federationTools->federationDiscovery()->discover($trustAnchorId); ## Entity Collection Client -The Entity Collection Client allows fetching pre-filtered lists of entities from a remote `federation_collection_endpoint`. This is much more efficient than full traversal if the remote side supports it. +The Entity Collection Client allows fetching pre-filtered lists of entities +from a remote `federation_collection_endpoint`. This is much more efficient +than full traversal if the remote side supports it. ### Bulk Fetching with Filters @@ -128,7 +149,7 @@ The client supports all standard OpenID Federation query parameters: ```php $endpoint = 'https://federation.example.org/collection'; -$collection = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint( +$collection = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint( $endpoint, [ 'entity_type' => ['openid_provider'], @@ -136,7 +157,7 @@ $collection = $federationTools->federationDiscovery()->discoverFromCollectionEnd 'trust_anchor' => 'https://trust-anchor.example.org/', 'query' => 'university', 'limit' => 50, - 'entity_claims' => ['display_name', 'contacts'], // Request specific claims + 'entity_claims' => ['entity_types', 'ui_infos'], // Request specific claims ] ); @@ -147,18 +168,20 @@ foreach ($collection->getEntities() as $id => $payload) { ### Client-Side Caching -`discoverFromCollectionEndpoint()` automatically caches the remote response body. If you need fresh data, pass `forceRefresh: true`. +`fetchFromCollectionEndpoint()` automatically caches the remote response +body. If you need fresh data, pass `forceRefresh: true`. ### Pagination Handling -The `EntityCollection` object encapsulates the `next` cursor for seamless pagination: +The `EntityCollection` object encapsulates the `next` cursor for seamless +pagination: ```php $results = []; $cursor = null; do { - $page = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint( + $page = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint( $endpoint, ['limit' => 100, 'from' => $cursor] ); @@ -172,11 +195,14 @@ do { ## Server-Side Implementation -If you are implementing your own `federation_collection_endpoint`, the library provides high-level building blocks to handle filtering, sorting, and pagination. +If you are implementing your own `federation_collection_endpoint`, the library +provides high-level building blocks to handle filtering, sorting, and +pagination. ### The Pipeline Pattern -The recommended implementation follows this pipeline: **Discover → Filter → Sort → Paginate → Serialize**. +The recommended implementation follows this pipeline: +**Discover → Filter → Sort → Paginate → Serialize**. ```php public function __invoke(ServerRequestInterface $request): ResponseInterface @@ -192,7 +218,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface // 3. Sort (By nested metadata claims) if (isset($params['sort_by'])) { - $path = explode('.', $params['sort_by']); // e.g. "federation_entity.display_name" + $path = explode('.', $params['sort_by']); // e.g. "metadata.federation_entity.display_name" $collection->sort([$path], $params['sort_dir'] ?? 'asc'); } @@ -217,7 +243,9 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface ### Sorting Technical Details -The `sort()` method accepts an array of claim paths relative to the **JWT payload root**. When sorting by metadata claims, you must explicitly include the `metadata` prefix: +The `sort()` method accepts an array of claim paths relative to the +**JWT payload root**. When sorting by metadata claims, you must explicitly +include the `metadata` prefix: ```php $collection->sort([ @@ -231,7 +259,8 @@ $collection->sort([ ## Serialized Response Format -The `toCollectionEndpointResponseArray()` method produces a structure compatible with the OpenID Federation specification: +The `toCollectionEndpointResponseArray()` method produces a structure compatible +with the OpenID Federation specification: ```json { diff --git a/src/Federation.php b/src/Federation.php index cab70b0..a4cd55e 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -352,6 +352,7 @@ public function subordinateListingFetcher(): SubordinateListingFetcher return $this->subordinateListingFetcher ??= new SubordinateListingFetcher( $this->artifactFetcher(), $this->helpers(), + $this->maxCacheDurationDecorator(), $this->logger, ); } diff --git a/src/Federation/FederationDiscovery.php b/src/Federation/FederationDiscovery.php index d3c91c8..486eb0c 100644 --- a/src/Federation/FederationDiscovery.php +++ b/src/Federation/FederationDiscovery.php @@ -71,7 +71,7 @@ public function discover( $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); // Recursive traversal - $discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters); + $discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters, 0, [], $forceRefresh); // Compute TTL: lowest of maxCacheDuration and TA expiry $ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime( @@ -115,7 +115,7 @@ public function discover( * } $filters * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException */ - public function discoverFromCollectionEndpoint( + public function fetchFromCollectionEndpoint( string $endpointUri, array $filters = [], bool $forceRefresh = false, @@ -157,7 +157,7 @@ public function discoverFromCollectionEndpoint( } - private function buildEntityCollectionFromResponse(string $responseBody): EntityCollection + protected function buildEntityCollectionFromResponse(string $responseBody): EntityCollection { $decoded = $this->helpers->json()->decode($responseBody); @@ -237,12 +237,13 @@ public function discoverEntityIds( * @param string[] $visited * @return array> */ - private function traverse( + protected function traverse( string $entityId, EntityStatement $entityConfig, array $filters, int $depth = 0, array $visited = [], + bool $forceRefresh = false, ): array { if ($depth > $this->maxDepth || in_array($entityId, $visited, true)) { return []; @@ -257,7 +258,7 @@ private function traverse( } try { - $subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters); + $subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters, $forceRefresh); foreach ($subordinateIds as $subId) { // If we've already visited this subId (loop), skip to avoid infinite recursion @@ -269,7 +270,7 @@ private function traverse( $subConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($subId); $allCollectedEntities = array_merge( $allCollectedEntities, - $this->traverse($subId, $subConfig, $filters, $depth + 1, $visited), + $this->traverse($subId, $subConfig, $filters, $depth + 1, $visited, $forceRefresh), ); } catch (Throwable $e) { $this->logger?->warning('Failed to fetch subordinate configuration during discovery.', [ diff --git a/src/Federation/SubordinateListingFetcher.php b/src/Federation/SubordinateListingFetcher.php index aa59008..472f2c2 100644 --- a/src/Federation/SubordinateListingFetcher.php +++ b/src/Federation/SubordinateListingFetcher.php @@ -5,7 +5,7 @@ namespace SimpleSAML\OpenID\Federation; use Psr\Log\LoggerInterface; -use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\EntityDiscoveryException; use SimpleSAML\OpenID\Helpers; use SimpleSAML\OpenID\Utils\ArtifactFetcher; @@ -16,6 +16,7 @@ class SubordinateListingFetcher public function __construct( protected readonly ArtifactFetcher $artifactFetcher, protected readonly Helpers $helpers, + protected readonly DateIntervalDecorator $maxCacheDurationDecorator, protected readonly ?LoggerInterface $logger = null, ) { } @@ -26,27 +27,41 @@ public function __construct( * * @param non-empty-string $listEndpointUri * @param array $filters Optional query params: entity_type, intermediate, etc. + * @param bool $forceRefresh If true, ignore cached listing and fetch from network. * @return non-empty-string[] * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException */ - public function fetch(string $listEndpointUri, array $filters = []): array + public function fetch(string $listEndpointUri, array $filters = [], bool $forceRefresh = false): array { $uri = $this->helpers->url()->withMultiValueParams($listEndpointUri, $filters); - $this->logger?->debug('Fetching subordinate listing.', ['uri' => $uri, 'filters' => $filters]); + if (!$forceRefresh) { + $this->logger?->debug('Checking for cached subordinate listing.', ['uri' => $uri]); + $cached = $this->artifactFetcher->fromCacheAsString($uri); + if (is_string($cached)) { + $this->logger?->debug('Returning cached subordinate listing.', ['uri' => $uri]); + return $this->decodeAndEnsureType($cached); + } + + $this->logger?->debug('No cached subordinate listing found.', ['uri' => $uri]); + } + + $this->logger?->debug('Fetching subordinate listing from network.', ['uri' => $uri, 'filters' => $filters]); try { $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); $this->logger?->debug('Fetched subordinate listing from network.', ['uri' => $uri]); - $decoded = $this->helpers->json()->decode($responseBody); + $result = $this->decodeAndEnsureType($responseBody); - if (!is_array($decoded)) { - throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); - } + $this->artifactFetcher->cacheIt( + $responseBody, + $this->maxCacheDurationDecorator->getInSeconds(), + $uri, + ); - return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, ClaimsEnum::Sub->value); + return $result; } catch (Throwable $throwable) { $message = sprintf( 'Unable to fetch subordinate listing from %s. Error: %s', @@ -57,4 +72,20 @@ public function fetch(string $listEndpointUri, array $filters = []): array throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); } } + + + /** + * @return non-empty-string[] + * @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException + */ + protected function decodeAndEnsureType(string $responseBody): array + { + $decoded = $this->helpers->json()->decode($responseBody); + + if (!is_array($decoded)) { + throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); + } + + return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, 'Subordinate Listing'); + } } diff --git a/tests/src/Federation/SubordinateListingFetcherTest.php b/tests/src/Federation/SubordinateListingFetcherTest.php new file mode 100644 index 0000000..1cb2e48 --- /dev/null +++ b/tests/src/Federation/SubordinateListingFetcherTest.php @@ -0,0 +1,161 @@ +artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $this->typeHelperMock = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class); + + // Set up common helper mocks + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn('http://example.com/list'); + $this->helpersMock->method('url')->willReturn($urlHelper); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $this->helpersMock->method('type')->willReturn($this->typeHelperMock); + } + + + protected function sut(): SubordinateListingFetcher + { + return new SubordinateListingFetcher( + $this->artifactFetcherMock, + $this->helpersMock, + $this->maxCacheDurationMock, + $this->createStub(\Psr\Log\LoggerInterface::class), + ); + } + + + public function testFetchReturnsCachedDataIfAvailable(): void + { + $uri = 'http://example.com/list'; + $cachedResponse = '["sub1", "sub2"]'; + $decodedResponse = ['sub1', 'sub2']; + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($uri) + ->willReturn($cachedResponse); + + $this->artifactFetcherMock->expects($this->never()) + ->method('fromNetworkAsString'); + + $this->jsonHelperMock->expects($this->once()) + ->method('decode') + ->with($cachedResponse) + ->willReturn($decodedResponse); + + $this->typeHelperMock->expects($this->once()) + ->method('ensureArrayWithValuesAsNonEmptyStrings') + ->with($decodedResponse) + ->willReturn($decodedResponse); + + $result = $this->sut()->fetch($uri); + $this->assertSame($decodedResponse, $result); + } + + + public function testFetchFromNetworkOnCacheMissAndCachesResult(): void + { + $uri = 'http://example.com/list'; + $networkResponse = '["sub3"]'; + $decodedResponse = ['sub3']; + $ttl = 3600; + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($uri) + ->willReturn(null); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($uri) + ->willReturn($networkResponse); + + $this->jsonHelperMock->expects($this->once()) + ->method('decode') + ->with($networkResponse) + ->willReturn($decodedResponse); + + $this->typeHelperMock->expects($this->once()) + ->method('ensureArrayWithValuesAsNonEmptyStrings') + ->with($decodedResponse) + ->willReturn($decodedResponse); + + $this->maxCacheDurationMock->method('getInSeconds')->willReturn($ttl); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($networkResponse, $ttl, $uri); + + $result = $this->sut()->fetch($uri); + $this->assertSame($decodedResponse, $result); + } + + + public function testForceRefreshBypassesCache(): void + { + $uri = 'http://example.com/list'; + $networkResponse = '["sub4"]'; + $decodedResponse = ['sub4']; + + $this->artifactFetcherMock->expects($this->never()) + ->method('fromCacheAsString'); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($uri) + ->willReturn($networkResponse); + + $this->jsonHelperMock->method('decode')->willReturn($decodedResponse); + $this->typeHelperMock->method('ensureArrayWithValuesAsNonEmptyStrings')->willReturn($decodedResponse); + + $result = $this->sut()->fetch($uri, [], true); + $this->assertSame($decodedResponse, $result); + } + + + public function testFetchThrowsExceptionOnInvalidJson(): void + { + $uri = 'http://example.com/list'; + $invalidJson = 'invalid'; + + $this->artifactFetcherMock->method('fromCacheAsString')->willReturn(null); + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($invalidJson); + $this->jsonHelperMock->method('decode')->willReturn(null); // Invalid JSON decodes to null + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('JSON array'); + + $this->sut()->fetch($uri); + } +} From 74883f5048da8e0cbf78801ed719cbdceaa5aae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 1 May 2026 20:38:08 +0200 Subject: [PATCH 16/16] WIP --- docs/1-openid.md | 3 +- src/Helpers/Arr.php | 8 +- tests/src/Core/LogoutTokenTest.php | 282 ++++++++++ .../CacheEntityCollectionStoreTest.php | 167 ++++++ .../EntityCollectionFilterTest.php | 188 +++++++ .../EntityCollectionPaginatorTest.php | 105 ++++ .../EntityCollectionSorterTest.php | 153 +++++ .../InMemoryEntityCollectionStoreTest.php | 68 +++ tests/src/Federation/EntityCollectionTest.php | 243 ++++++++ .../Federation/FederationDiscoveryTest.php | 522 ++++++++++++++++++ tests/src/FederationTest.php | 22 + tests/src/Helpers/ArrTest.php | 67 +++ 12 files changed, 1823 insertions(+), 5 deletions(-) create mode 100644 tests/src/Core/LogoutTokenTest.php create mode 100644 tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php create mode 100644 tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php create mode 100644 tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php create mode 100644 tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php create mode 100644 tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php create mode 100644 tests/src/Federation/EntityCollectionTest.php create mode 100644 tests/src/Federation/FederationDiscoveryTest.php diff --git a/docs/1-openid.md b/docs/1-openid.md index 2dc65dd..e98acb6 100644 --- a/docs/1-openid.md +++ b/docs/1-openid.md @@ -2,6 +2,5 @@ 1. [Installation](2-installation.md) 2. [OpenID Federation Tools](3-federation.md) -2.1 [Federation Discovery](3.1-federation-discovery.md) +2.1 [Federation Discovery and Entity Collection](3.1-federation-discovery.md) 3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md) -4. [Federation Discovery and Entity Collection](5-federation-discovery.md) diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index cd14b32..42f3a91 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -135,9 +135,7 @@ public function addNestedValue(array &$array, mixed $value, int|string ...$keys) */ public function getNestedValue(array $array, int|string ...$keys): mixed { - if (count($keys) > 99) { - throw new OpenIdException('Refusing to recurse to given depth.'); - } + $this->validateMaxDepth(count($keys)); if (count($keys) < 1) { return null; @@ -162,6 +160,10 @@ public function getNestedValue(array $array, int|string ...$keys): mixed */ public function isAssociative(array $array): bool { + if ($array === []) { + return false; + } + // Has at least one string key or non-sequential numeric keys return array_keys($array) !== range(0, count($array) - 1); } diff --git a/tests/src/Core/LogoutTokenTest.php b/tests/src/Core/LogoutTokenTest.php new file mode 100644 index 0000000..6881cb5 --- /dev/null +++ b/tests/src/Core/LogoutTokenTest.php @@ -0,0 +1,282 @@ +createMock(JWS::class); + $jwsMock->method('getPayload') + ->willReturn('json-payload-string'); + + $this->jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $this->jwsDecoratorMock->method('jws')->willReturn($jwsMock); + + $this->jwsVerifierDecoratorMock = $this->createStub(JwsVerifierDecorator::class); + $this->jwksDecoratorFactoryMock = $this->createStub(JwksDecoratorFactory::class); + $this->jwsSerializerManagerDecoratorMock = $this->createStub(JwsSerializerManagerDecorator::class); + $this->dateIntervalDecoratorMock = $this->createStub(DateIntervalDecorator::class); + + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $typeHelperMock = $this->createMock(Helpers\Type::class); + $this->helpersMock->method('type')->willReturn($typeHelperMock); + + $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); + $typeHelperMock->method('ensureInt')->willReturnArgument(0); + $typeHelperMock->method('enforceUri')->willReturnArgument(0); + $typeHelperMock->method('ensureArrayWithValuesAsStrings')->willReturnArgument(0); + + $this->claimFactoryMock = $this->createStub(ClaimFactory::class); + + $this->validPayload = [ + 'iss' => 'https://server.example.com', + 'sub' => '24400320', + 'aud' => 's6BhdRkqt3', + 'iat' => time(), + 'exp' => time() + 3600, + 'jti' => 'bWJq', + 'events' => [ + 'http://schemas.openid.net/event/backchannel-logout' => (object) [], + ], + ]; + } + + + protected function sut( + ?JwsDecorator $jwsDecorator = null, + ?JwsVerifierDecorator $jwsVerifierDecorator = null, + ?JwksDecoratorFactory $jwksDecoratorFactory = null, + ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, + ?DateIntervalDecorator $dateIntervalDecorator = null, + ?Helpers $helpers = null, + ?ClaimFactory $claimFactory = null, + ): LogoutToken { + $jwsDecorator ??= $this->jwsDecoratorMock; + $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; + $jwksDecoratorFactory ??= $this->jwksDecoratorFactoryMock; + $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerDecoratorMock; + $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; + $helpers ??= $this->helpersMock; + $claimFactory ??= $this->claimFactoryMock; + + return new LogoutToken( + $jwsDecorator, + $jwsVerifierDecorator, + $jwksDecoratorFactory, + $jwsSerializerManagerDecorator, + $dateIntervalDecorator, + $helpers, + $claimFactory, + ); + } + + + public function testCanCreateInstance(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->validPayload); + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } + + + public function testCanGetRequiredClaims(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->validPayload); + $sut = $this->sut(); + + $this->assertSame($this->validPayload['iss'], $sut->getIssuer()); + $this->assertSame([$this->validPayload['aud']], $sut->getAudience()); + $this->assertSame($this->validPayload['iat'], $sut->getIssuedAt()); + $this->assertSame($this->validPayload['exp'], $sut->getExpirationTime()); + $this->assertSame($this->validPayload['jti'], $sut->getJwtId()); + $this->assertSame($this->validPayload['events'], $sut->getEvents()); + } + + + public function testGetIssuerThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['iss']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Issuer claim found.'); + + $this->sut(); + } + + + public function testGetAudienceThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['aud']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Audience claim found.'); + + $this->sut(); + } + + + public function testGetIssuedAtThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['iat']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Issued At claim found.'); + + $this->sut(); + } + + + public function testGetExpirationTimeThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['exp']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Expiration Time claim found.'); + + $this->sut(); + } + + + public function testGetJwtIdThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['jti']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No JWT ID claim found.'); + + $this->sut(); + } + + + public function testGetEventsThrowsWhenMissing(): void + { + $payload = $this->validPayload; + unset($payload['events']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('No Events claim found.'); + + $this->sut(); + } + + + public function testGetEventsThrowsWhenMalformed(): void + { + $payload = $this->validPayload; + $payload['events'] = ['wrong-event' => []]; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Malformed events claim.'); + + $this->sut(); + } + + + public function testCanGetSessionId(): void + { + $payload = $this->validPayload; + $payload['sid'] = 'session-id'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertSame('session-id', $this->sut()->getSessionId()); + } + + + public function testGetNonceThrowsWhenPresent(): void + { + $payload = $this->validPayload; + $payload['nonce'] = 'some-nonce'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Nonce claim is forbidden in Logout Token.'); + + $this->sut(); + } + + + public function testThrowsWhenBothSubAndSidAreMissing(): void + { + $payload = $this->validPayload; + unset($payload['sub']); + unset($payload['sid']); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Missing Subject and Session ID claim in Logout Token.'); + + $this->sut(); + } + + + public function testDoesNotThrowWhenSubIsMissingButSidIsPresent(): void + { + $payload = $this->validPayload; + unset($payload['sub']); + $payload['sid'] = 'session-id'; + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } + + + public function testDoesNotThrowWhenSidIsMissingButSubIsPresent(): void + { + $payload = $this->validPayload; + unset($payload['sid']); + // sub is already in validPayload + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertInstanceOf(LogoutToken::class, $this->sut()); + } +} diff --git a/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php b/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php new file mode 100644 index 0000000..a6e132c --- /dev/null +++ b/tests/src/Federation/EntityCollection/CacheEntityCollectionStoreTest.php @@ -0,0 +1,167 @@ +cacheDecorator = $this->createMock(CacheDecorator::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->store = new CacheEntityCollectionStore( + $this->cacheDecorator, + $this->createStub(Helpers::class), + $this->logger, + ); + } + + + public function testStore(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->cacheDecorator->expects($this->once()) + ->method('set') + ->with($entities, 3600, 'federation_entities', 'anchor'); + + $this->store->store('anchor', $entities, 3600); + } + + + public function testStoreFailureLogsError(): void + { + $this->cacheDecorator->method('set')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->store('anchor', [], 3600); + } + + + public function testGet(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->cacheDecorator->expects($this->once()) + ->method('get') + ->with(null, 'federation_entities', 'anchor') + ->willReturn($entities); + + $this->assertSame($entities, $this->store->get('anchor')); + } + + + public function testGetReturnsNullIfNotArray(): void + { + $this->cacheDecorator->method('get')->willReturn('not-an-array'); + $this->assertNull($this->store->get('anchor')); + } + + + public function testGetFailureLogsError(): void + { + $this->cacheDecorator->method('get')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testClear(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('delete') + ->with('federation_entities', 'anchor'); + + $this->store->clear('anchor'); + } + + + public function testClearFailureLogsError(): void + { + $this->cacheDecorator->method('delete')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->clear('anchor'); + } + + + public function testStoreLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('set') + ->with('123456789', 3600, 'last_updated', 'anchor'); + + $this->store->storeLastUpdated('anchor', 123456789, 3600); + } + + + public function testGetLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('get') + ->with(null, 'last_updated', 'anchor') + ->willReturn(123456789); + + $this->assertSame(123456789, $this->store->getLastUpdated('anchor')); + } + + + public function testGetLastUpdatedReturnsNullIfNotInt(): void + { + $this->cacheDecorator->method('get')->willReturn('string'); + $this->assertNull($this->store->getLastUpdated('anchor')); + } + + + public function testClearLastUpdated(): void + { + $this->cacheDecorator->expects($this->once()) + ->method('delete') + ->with('last_updated', 'anchor'); + + $this->store->clearLastUpdated('anchor'); + } + + + public function testStoreLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('set')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->storeLastUpdated('anchor', 123456789, 3600); + } + + + public function testClearLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('delete')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->store->clearLastUpdated('anchor'); + } + + + public function testGetLastUpdatedFailureLogsError(): void + { + $this->cacheDecorator->method('get')->willThrowException(new \Exception('error')); + $this->logger->expects($this->once())->method('error'); + + $this->assertNull($this->store->getLastUpdated('anchor')); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php b/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php new file mode 100644 index 0000000..eefe772 --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionFilterTest.php @@ -0,0 +1,188 @@ +filter = new EntityCollectionFilter($this->createStub(Helpers::class)); + } + + + public function testFilterByEntityType(): void + { + $entities = [ + 'idp' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [], + ], + ], + 'rp' => [ + ClaimsEnum::Metadata->value => [ + 'openid_relying_party' => [], + ], + ], + 'both' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [], + 'openid_relying_party' => [], + ], + ], + 'none' => [ + ClaimsEnum::Metadata->value => [], + ], + 'invalid' => [ + ClaimsEnum::Metadata->value => 'string', + ], + ]; + + // Filter by openid_provider + $result = $this->filter->filter($entities, ['entity_type' => ['openid_provider']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('idp', $result); + $this->assertArrayHasKey('both', $result); + + // Filter by openid_relying_party + $result = $this->filter->filter($entities, ['entity_type' => ['openid_relying_party']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('rp', $result); + $this->assertArrayHasKey('both', $result); + + // Filter by both + $result = $this->filter->filter($entities, ['entity_type' => ['openid_provider', 'openid_relying_party']]); + $this->assertCount(3, $result); + $this->assertArrayHasKey('idp', $result); + $this->assertArrayHasKey('rp', $result); + $this->assertArrayHasKey('both', $result); + } + + + public function testFilterByTrustMarkType(): void + { + $entities = [ + 'm1' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type1'], + ], + ], + 'm12' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type1'], + [ClaimsEnum::TrustMarkType->value => 'type2'], + ], + ], + 'm2' => [ + ClaimsEnum::TrustMarks->value => [ + [ClaimsEnum::TrustMarkType->value => 'type2'], + ], + ], + 'none' => [], + 'invalid' => [ + ClaimsEnum::TrustMarks->value => 'string', + ], + ]; + + // Filter by type1 + $result = $this->filter->filter($entities, ['trust_mark_type' => ['type1']]); + $this->assertCount(2, $result); + $this->assertArrayHasKey('m1', $result); + $this->assertArrayHasKey('m12', $result); + + // Filter by type1 AND type2 + $result = $this->filter->filter($entities, ['trust_mark_type' => ['type1', 'type2']]); + $this->assertCount(1, $result); + $this->assertArrayHasKey('m12', $result); + } + + + public function testFilterByQuery(): void + { + $entities = [ + 'idp' => [ + ClaimsEnum::Sub->value => 'https://idp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [ + ClaimsEnum::DisplayName->value => 'Example IdP', + ClaimsEnum::OrganizationName->value => 'Example Org', + ], + ], + ], + 'rp' => [ + ClaimsEnum::Sub->value => 'https://rp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_relying_party' => [ + ClaimsEnum::DisplayName->value => 'Example RP', + ], + ], + ], + 'other' => [ + ClaimsEnum::Sub->value => 'https://other.example.com', + ClaimsEnum::Metadata->value => [ + 'federation_entity' => [ + ClaimsEnum::OrganizationName->value => 'Other Org', + ], + ], + ], + ]; + + // Query by sub + $result = $this->filter->filter($entities, ['query' => 'idp']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('idp', $result); + + // Query by display_name + $result = $this->filter->filter($entities, ['query' => 'IdP']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('idp', $result); + + // Query by organization_name + $result = $this->filter->filter($entities, ['query' => 'Other']); + $this->assertCount(1, $result); + $this->assertArrayHasKey('other', $result); + + // Query with no results + $result = $this->filter->filter($entities, ['query' => 'nomatch']); + $this->assertCount(0, $result); + } + + + public function testFilterWithInvalidMetadataStructures(): void + { + $entities = [ + 'invalid_metadata' => [ + ClaimsEnum::Metadata->value => 'not-an-array', + ], + 'invalid_trustmarks' => [ + ClaimsEnum::TrustMarks->value => 'not-an-array', + ], + 'invalid_type_payload' => [ + ClaimsEnum::Metadata->value => [ + 'openid_provider' => 'not-an-array', + ], + ], + ]; + + // invalid_metadata and invalid_trustmarks are excluded (return false on line 50) + // invalid_type_payload is INCLUDED because isset($metadata['openid_provider']) is true + $this->assertCount(1, $this->filter->filter($entities, ['entity_type' => ['openid_provider']])); + + // all are excluded for trust_mark_type + $this->assertCount(0, $this->filter->filter($entities, ['trust_mark_type' => ['type1']])); + + // all are excluded for query + $this->assertCount(0, $this->filter->filter($entities, ['query' => 'something'])); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php b/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php new file mode 100644 index 0000000..eda5413 --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionPaginatorTest.php @@ -0,0 +1,105 @@ +createMock(Helpers::class); + $this->base64Url = $this->createMock(Base64Url::class); + $helpers->method('base64Url')->willReturn($this->base64Url); + $this->paginator = new EntityCollectionPaginator($helpers); + } + + + public function testPaginateFirstPage(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ]; + + $this->base64Url->expects($this->once()) + ->method('encode') + ->with('id2') + ->willReturn('YmFzZTY0LWlkMg'); + + $result = $this->paginator->paginate($entities, 2); + + $expected = [ + ClaimsEnum::Entities->value => [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + ], + ClaimsEnum::Next->value => 'YmFzZTY0LWlkMg', + ]; + + $this->assertSame($expected, $result); + } + + + public function testPaginateSecondPage(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ]; + + $this->base64Url->expects($this->once()) + ->method('decode') + ->with('YmFzZTY0LWlkMQ') + ->willReturn('id1'); + + // No more pages after this one (limit 2, offset 1 means id2, id3 are returned) + $result = $this->paginator->paginate($entities, 2, 'YmFzZTY0LWlkMQ'); + + $expected = [ + ClaimsEnum::Entities->value => [ + 'id2' => ['sub' => 'id2'], + 'id3' => ['sub' => 'id3'], + ], + ClaimsEnum::Next->value => null, + ]; + + $this->assertSame($expected, $result); + } + + + public function testPaginateInvalidCursor(): void + { + $entities = [ + 'id1' => ['sub' => 'id1'], + 'id2' => ['sub' => 'id2'], + ]; + + $this->base64Url->expects($this->once()) + ->method('decode') + ->with('invalid') + ->willReturn('non-existent'); + + // If cursor is not found, it starts from the beginning (offset 0) + $result = $this->paginator->paginate($entities, 1, 'invalid'); + + $this->assertArrayHasKey('id1', $result[ClaimsEnum::Entities->value]); + $this->assertCount(1, $result[ClaimsEnum::Entities->value]); + } +} diff --git a/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php b/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php new file mode 100644 index 0000000..371131e --- /dev/null +++ b/tests/src/Federation/EntityCollection/EntityCollectionSorterTest.php @@ -0,0 +1,153 @@ +createMock(Helpers::class); + $this->arr = $this->createMock(Arr::class); + $helpers->method('arr')->willReturn($this->arr); + $this->sorter = new EntityCollectionSorter($helpers); + } + + + public function testSortAscending(): void + { + $entities = [ + 'z' => ['val' => 'Zebra'], + 'a' => ['val' => 'Apple'], + 'm' => ['val' => 'Monkey'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + $this->assertSame(['a', 'm', 'z'], array_keys($result)); + } + + + public function testSortDescending(): void + { + $entities = [ + 'z' => ['val' => 'Zebra'], + 'a' => ['val' => 'Apple'], + 'm' => ['val' => 'Monkey'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'desc'); + + $this->assertSame(['z', 'm', 'a'], array_keys($result)); + } + + + public function testSortMissingClaim(): void + { + $entities = [ + 'a' => ['val' => 'Apple'], + 'b' => [], // Missing 'val' + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(function (array $arr, string $path) { + if (!isset($arr[$path])) { + throw new OpenIdException('Missing'); + } + + return $arr[$path]; + }); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + // 'b' (null/empty string) should come before 'a' ('Apple') + $this->assertSame(['b', 'a'], array_keys($result)); + } + + + public function testSortMultiplePaths(): void + { + $entities = [ + 'id1' => ['v1' => 'A', 'v2' => 'B'], + 'id2' => ['v1' => 'A', 'v2' => 'A'], + ]; + + $claimPaths = [['v1'], ['v2']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr, string $path): mixed => $arr[$path]); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + $this->assertSame(['id2', 'id1'], array_keys($result)); + } + + + public function testSortEmptyEntities(): void + { + $this->assertSame([], $this->sorter->sort([], [['any']])); + } + + + public function testSortEqualValues(): void + { + $entities = [ + 'id1' => ['val' => 'A'], + 'id2' => ['val' => 'A'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue')->willReturn('A'); + + $result = $this->sorter->sort($entities, $claimPaths, 'asc'); + + // Order should be preserved if equal + $this->assertSame(['id1', 'id2'], array_keys($result)); + } + + + public function testSortDescendingDifferentValues(): void + { + $entities = [ + 'id1' => ['val' => 'A'], + 'id2' => ['val' => 'B'], + ]; + + $claimPaths = [['val']]; + + $this->arr->method('getNestedValue') + ->willReturnCallback(fn(array $arr): mixed => $arr['val']); + + $result = $this->sorter->sort($entities, $claimPaths, 'desc'); + + $this->assertSame(['id2', 'id1'], array_keys($result)); + } +} diff --git a/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php b/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php new file mode 100644 index 0000000..673b426 --- /dev/null +++ b/tests/src/Federation/EntityCollection/InMemoryEntityCollectionStoreTest.php @@ -0,0 +1,68 @@ +store = new InMemoryEntityCollectionStore(); + } + + + public function testStoreAndGet(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->store->store('anchor', $entities, 3600); + + $this->assertSame($entities, $this->store->get('anchor')); + } + + + public function testGetExpired(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + // Store with negative TTL so it's immediately expired + $this->store->store('anchor', $entities, -10); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testGetNonExistent(): void + { + $this->assertNull($this->store->get('non-existent')); + } + + + public function testClear(): void + { + $entities = ['id1' => ['sub' => 'id1']]; + $this->store->store('anchor', $entities, 3600); + $this->store->clear('anchor'); + + $this->assertNull($this->store->get('anchor')); + } + + + public function testLastUpdated(): void + { + $timestamp = 123456789; + $this->store->storeLastUpdated('anchor', $timestamp, 3600); + + $this->assertSame($timestamp, $this->store->getLastUpdated('anchor')); + + $this->store->clearLastUpdated('anchor'); + $this->assertNull($this->store->getLastUpdated('anchor')); + } +} diff --git a/tests/src/Federation/EntityCollectionTest.php b/tests/src/Federation/EntityCollectionTest.php new file mode 100644 index 0000000..4e2cdfc --- /dev/null +++ b/tests/src/Federation/EntityCollectionTest.php @@ -0,0 +1,243 @@ +filter = $this->createMock(EntityCollectionFilter::class); + $this->sorter = $this->createMock(EntityCollectionSorter::class); + $this->paginator = $this->createMock(EntityCollectionPaginator::class); + } + + + public function testGetEntities(): void + { + $entities = ['https://idp.example.com' => ['sub' => 'https://idp.example.com']]; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $this->assertSame($entities, $collection->getEntities()); + } + + + public function testGetLastUpdated(): void + { + $lastUpdated = 123456789; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + null, + $lastUpdated, + ); + + $this->assertSame($lastUpdated, $collection->getLastUpdated()); + } + + + public function testGetNextPageToken(): void + { + $token = 'opaque-token'; + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + $token, + ); + + $this->assertSame($token, $collection->getNextPageToken()); + } + + + public function testToCollectionEndpointResponseArray(): void + { + $entities = [ + 'https://idp.example.com' => [ + ClaimsEnum::Sub->value => 'https://idp.example.com', + ClaimsEnum::Metadata->value => [ + 'openid_provider' => [ + 'issuer' => 'https://idp.example.com', + ], + ], + ClaimsEnum::TrustMarks->value => [ + ['id' => 'mark1'], + ], + ], + 'https://rp.example.com' => [ + ClaimsEnum::Sub->value => 'https://rp.example.com', + // No metadata + ], + 'https://broken.example.com' => [ + ClaimsEnum::Sub->value => 'https://broken.example.com', + ClaimsEnum::Metadata->value => 'invalid-metadata', // Not an array + ], + ]; + + $lastUpdated = 1620000000; + $nextToken = 'next-page'; + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + $nextToken, + $lastUpdated, + ); + + $expected = [ + ClaimsEnum::Entities->value => [ + [ + ClaimsEnum::EntityId->value => 'https://idp.example.com', + ClaimsEnum::EntityTypes->value => ['openid_provider'], + ClaimsEnum::UiInfos->value => [ + 'openid_provider' => [ + 'issuer' => 'https://idp.example.com', + ], + ], + ClaimsEnum::TrustMarks->value => [ + ['id' => 'mark1'], + ], + ], + [ + ClaimsEnum::EntityId->value => 'https://rp.example.com', + ClaimsEnum::EntityTypes->value => [], + ], + [ + ClaimsEnum::EntityId->value => 'https://broken.example.com', + ClaimsEnum::EntityTypes->value => [], + ], + ], + ClaimsEnum::Next->value => $nextToken, + ClaimsEnum::LastUpdated->value => $lastUpdated, + ]; + + $this->assertSame($expected, $collection->toCollectionEndpointResponseArray()); + } + + + public function testToCollectionEndpointResponseArrayWithNulls(): void + { + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + [], + ); + + $expected = [ + ClaimsEnum::Entities->value => [], + ]; + + $this->assertSame($expected, $collection->toCollectionEndpointResponseArray()); + } + + + public function testFilter(): void + { + $entities = ['a' => []]; + $criteria = ['entity_type' => ['openid_provider']]; + $filtered = ['b' => []]; + + $this->filter->expects($this->once()) + ->method('filter') + ->with($entities, $criteria) + ->willReturn($filtered); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->filter($criteria); + + $this->assertSame($collection, $result); + $this->assertSame($filtered, $collection->getEntities()); + } + + + public function testSort(): void + { + $entities = ['a' => []]; + $claimPaths = [['metadata', 'openid_provider', 'organization_name']]; + $sortOrder = 'asc'; + $sorted = ['b' => []]; + + $this->sorter->expects($this->once()) + ->method('sort') + ->with($entities, $claimPaths, $sortOrder) + ->willReturn($sorted); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->sort($claimPaths, $sortOrder); + + $this->assertSame($collection, $result); + $this->assertSame($sorted, $collection->getEntities()); + } + + + public function testPaginate(): void + { + $entities = ['a' => [], 'b' => []]; + $limit = 1; + $from = 'cursor'; + $paginatedEntities = ['b' => []]; + $nextToken = 'next-cursor'; + + $this->paginator->expects($this->once()) + ->method('paginate') + ->with($entities, $limit, $from) + ->willReturn([ + 'entities' => $paginatedEntities, + 'next' => $nextToken, + ]); + + $collection = new EntityCollection( + $this->filter, + $this->sorter, + $this->paginator, + $entities, + ); + + $result = $collection->paginate($limit, $from); + + $this->assertSame($collection, $result); + $this->assertSame($paginatedEntities, $collection->getEntities()); + $this->assertSame($nextToken, $collection->getNextPageToken()); + } +} diff --git a/tests/src/Federation/FederationDiscoveryTest.php b/tests/src/Federation/FederationDiscoveryTest.php new file mode 100644 index 0000000..d522119 --- /dev/null +++ b/tests/src/Federation/FederationDiscoveryTest.php @@ -0,0 +1,522 @@ +entityStatementFetcherMock = $this->createMock(EntityStatementFetcher::class); + $this->subordinateListingFetcherMock = $this->createMock(SubordinateListingFetcher::class); + $this->entityCollectionStoreMock = $this->createMock(EntityCollectionStoreInterface::class); + $this->maxCacheDurationDecoratorMock = $this->createMock(DateIntervalDecorator::class); + $this->entityCollectionFactoryMock = $this->createMock(EntityCollectionFactory::class); + $this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + } + + + protected function sut(int $maxDepth = 10): FederationDiscovery + { + return new FederationDiscovery( + $this->entityStatementFetcherMock, + $this->subordinateListingFetcherMock, + $this->entityCollectionStoreMock, + $this->maxCacheDurationDecoratorMock, + $this->entityCollectionFactoryMock, + $this->artifactFetcherMock, + $this->helpersMock, + $this->loggerMock, + $maxDepth, + ); + } + + + public function testDiscoverReturnsCachedEntities(): void + { + $trustAnchorId = 'https://ta.example.org'; + $cachedEntities = ['https://entity.example.org' => ['sub' => 'https://entity.example.org']]; + $lastUpdated = 1234567890; + $collection = $this->createStub(EntityCollection::class); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('get') + ->with($trustAnchorId) + ->willReturn($cachedEntities); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('getLastUpdated') + ->with($trustAnchorId) + ->willReturn($lastUpdated); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with($cachedEntities, $lastUpdated) + ->willReturn($collection); + + $result = $this->sut()->discover($trustAnchorId); + $this->assertSame($collection, $result); + } + + + public function testDiscoverBypassesCacheOnForceRefresh(): void + { + $trustAnchorId = 'https://ta.example.org'; + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $trustAnchorId]); + $taConfig->method('getFederationListEndpoint')->willReturn(null); + + $this->entityCollectionStoreMock->expects($this->never()) + ->method('get'); + + $this->entityStatementFetcherMock->expects($this->once()) + ->method('fromCacheOrWellKnownEndpoint') + ->with($trustAnchorId) + ->willReturn($taConfig); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->discover($trustAnchorId, [], true); + $this->assertSame($collection, $result); + } + + + public function testDiscoverWithTraversal(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + $leafId = 'https://leaf.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $subConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $subConfig->method('getPayload')->willReturn(['sub' => $subId]); + $subConfig->method('getFederationListEndpoint')->willReturn('https://sub.example.org/list'); + + $leafConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $leafConfig->method('getPayload')->willReturn(['sub' => $leafId]); + $leafConfig->method('getFederationListEndpoint')->willReturn(null); + + $this->entityStatementFetcherMock->expects($this->exactly(3))->method('fromCacheOrWellKnownEndpoint') + ->willReturnMap([ + [$taId, $taConfig], + [$subId, $subConfig], + [$leafId, $leafConfig], + ]); + + $this->subordinateListingFetcherMock->expects($this->exactly(2))->method('fetch') + ->willReturnMap([ + ['https://ta.example.org/list', [], false, [$subId]], + ['https://sub.example.org/list', [], false, [$leafId]], + ]); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $expectedEntities = [ + $leafId => ['sub' => $leafId], + $subId => ['sub' => $subId], + $taId => ['sub' => $taId], + ]; + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, $expectedEntities, 3600); + + $this->sut()->discover($taId); + } + + + public function testDiscoverHandlesTraversalError(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint') + ->willReturnCallback(function ( + string $id, + ) use ( + $taId, + $taConfig, +): \PHPUnit\Framework\MockObject\MockObject { + if ($id === $taId) { + return $taConfig; + } + + throw new \Exception('Fetch failed'); + }); + + $this->subordinateListingFetcherMock->method('fetch') + ->with('https://ta.example.org/list') + ->willReturn([$subId]); + + $this->maxCacheDurationDecoratorMock->method('lowestInSecondsComparedToExpirationTime') + ->willReturn(3600); + + $expectedEntities = [ + $subId => [], // Should include subId with empty payload on failure + $taId => ['sub' => $taId], + ]; + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, $expectedEntities, 3600); + + $this->sut()->discover($taId); + } + + + public function testFetchFromCollectionEndpointSuccess(): void + { + $endpointUri = 'https://example.org/collection'; + $filters = ['entity_type' => ['openid_provider']]; + $fullUri = 'https://example.org/collection?entity_type=openid_provider'; + $responseBody = json_encode([ + 'entities' => [ + ['entity_id' => 'https://op1.example.org', 'ui_infos' => ['openid_provider' => ['name' => 'OP1']]], + ], + 'next' => 'https://example.org/collection?from=op2', + 'last_updated' => 1234567890, + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->with($endpointUri, $filters)->willReturn($fullUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(json_decode($responseBody, true)); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://op1.example.org'); + $typeHelper->method('ensureInt')->willReturn(1234567890); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($fullUri) + ->willReturn(null); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromNetworkAsString') + ->with($fullUri) + ->willReturn($responseBody); + + $this->maxCacheDurationDecoratorMock->method('getInSeconds')->willReturn(3600); + + $this->artifactFetcherMock->expects($this->once()) + ->method('cacheIt') + ->with($responseBody, 3600, $fullUri); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with( + ['https://op1.example.org' => [ + 'sub' => 'https://op1.example.org', + 'metadata' => ['openid_provider' => ['name' => 'OP1']], + ]], + 1234567890, + 'https://example.org/collection?from=op2', + ) + ->willReturn($collection); + + $result = $this->sut()->fetchFromCollectionEndpoint($endpointUri, $filters); + $this->assertSame($collection, $result); + } + + + public function testFetchFromCollectionEndpointFailure(): void + { + $endpointUri = 'https://example.org/collection'; + $fullUri = 'https://example.org/collection'; + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($fullUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString') + ->willThrowException(new \Exception('Network error')); + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('Unable to fetch entity collection'); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testDiscoverEntityIds(): void + { + $trustAnchorId = 'https://ta.example.org'; + $collection = $this->createMock(EntityCollection::class); + $collection->method('getEntities')->willReturn([ + 'https://e1.example.org' => [], + 'https://e2.example.org' => [], + ]); + + $this->entityCollectionStoreMock->method('get')->willReturn(['some' => 'data']); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->discoverEntityIds($trustAnchorId); + $this->assertSame(['https://e1.example.org', 'https://e2.example.org'], $result); + } + + + public function testTraverseRespectsMaxDepth(): void + { + $taId = 'https://ta.example.org'; + $subId = 'https://sub.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + $this->subordinateListingFetcherMock->method('fetch')->willReturn([$subId]); + + // sut with maxDepth = 0 + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, [$taId => ['sub' => $taId]], $this->anything()); + + $this->sut(0)->discover($taId); + } + + + public function testTraverseAvoidsLoops(): void + { + $taId = 'https://ta.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + // List endpoint returns the TA ID itself (a loop) + $this->subordinateListingFetcherMock->method('fetch')->willReturn([$taId]); + + $this->entityCollectionStoreMock->expects($this->once()) + ->method('store') + ->with($taId, [$taId => ['sub' => $taId]], $this->anything()); + + $this->sut()->discover($taId); + } + + + public function testDiscoverLogsErrorOnException(): void + { + $trustAnchorId = 'https://ta.example.org'; + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint') + ->willThrowException(new \Exception('Critical failure')); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Federation discovery failed.', $this->anything()); + + $this->sut()->discover($trustAnchorId); + } + + + public function testFetchFromCollectionEndpointReturnsCached(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode(['entities' => []]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->expects($this->once()) + ->method('fromCacheAsString') + ->with($endpointUri) + ->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(['entities' => []]); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $collection = $this->createStub(EntityCollection::class); + $this->entityCollectionFactoryMock->method('build')->willReturn($collection); + + $result = $this->sut()->fetchFromCollectionEndpoint($endpointUri); + $this->assertSame($collection, $result); + } + + + public function testBuildEntityCollectionFromResponseThrowsOnMissingEntities(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode(['invalid' => 'data']); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(['invalid' => 'data']); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $this->expectException(EntityDiscoveryException::class); + $this->expectExceptionMessage('missing "entities" array'); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testBuildEntityCollectionFromResponseSkipsNonArrayEntries(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode([ + 'entities' => [ + 'not-an-array', + ['entity_id' => 'https://valid.example.org'], + ], + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn([ + 'entities' => [ + 'not-an-array', + ['entity_id' => 'https://valid.example.org'], + ], + ]); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://valid.example.org'); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with(['https://valid.example.org' => [ + 'sub' => 'https://valid.example.org', + 'metadata' => [], + ]]) + ->willReturn($this->createStub(EntityCollection::class)); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testBuildEntityCollectionFromResponseWithTrustMarksAndLastUpdated(): void + { + $endpointUri = 'https://example.org/collection'; + $responseBody = json_encode([ + 'entities' => [ + [ + 'entity_id' => 'https://valid.example.org', + 'trust_marks' => [['id' => 'tm1']], + ], + ], + 'last_updated' => '1234567890', + ]); + + $urlHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Url::class); + $urlHelper->method('withMultiValueParams')->willReturn($endpointUri); + $this->helpersMock->method('url')->willReturn($urlHelper); + + $this->artifactFetcherMock->method('fromNetworkAsString')->willReturn($responseBody); + + $jsonHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Json::class); + $jsonHelper->method('decode')->willReturn(json_decode($responseBody, true)); + $this->helpersMock->method('json')->willReturn($jsonHelper); + + $typeHelper = $this->createMock(\SimpleSAML\OpenID\Helpers\Type::class); + $typeHelper->method('ensureNonEmptyString')->willReturn('https://valid.example.org'); + $typeHelper->method('ensureInt')->willReturn(1234567890); + $this->helpersMock->method('type')->willReturn($typeHelper); + + $this->entityCollectionFactoryMock->expects($this->once()) + ->method('build') + ->with( + ['https://valid.example.org' => [ + 'sub' => 'https://valid.example.org', + 'metadata' => [], + 'trust_marks' => [['id' => 'tm1']], + ]], + 1234567890, + ) + ->willReturn($this->createStub(EntityCollection::class)); + + $this->sut()->fetchFromCollectionEndpoint($endpointUri); + } + + + public function testTraverseHandlesSubordinateListingFailure(): void + { + $taId = 'https://ta.example.org'; + + $taConfig = $this->createMock(\SimpleSAML\OpenID\Federation\EntityStatement::class); + $taConfig->method('getExpirationTime')->willReturn(time() + 3600); + $taConfig->method('getPayload')->willReturn(['sub' => $taId]); + $taConfig->method('getFederationListEndpoint')->willReturn('https://ta.example.org/list'); + + $this->entityStatementFetcherMock->method('fromCacheOrWellKnownEndpoint')->willReturn($taConfig); + $this->subordinateListingFetcherMock->method('fetch')->willThrowException(new \Exception('Listing failed')); + + $this->loggerMock->expects($this->once()) + ->method('error') + ->with('Failed to fetch subordinate listing during discovery.', $this->anything()); + + $this->sut()->discover($taId); + } +} diff --git a/tests/src/FederationTest.php b/tests/src/FederationTest.php index ecbfd8f..2949865 100644 --- a/tests/src/FederationTest.php +++ b/tests/src/FederationTest.php @@ -22,14 +22,22 @@ use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerDecoratorFactory; use SimpleSAML\OpenID\Federation; +use SimpleSAML\OpenID\Federation\EntityCollection\CacheEntityCollectionStore; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter; +use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface; use SimpleSAML\OpenID\Federation\EntityStatementFetcher; +use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkDelegationFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; +use SimpleSAML\OpenID\Federation\FederationDiscovery; use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; +use SimpleSAML\OpenID\Federation\SubordinateListingFetcher; use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Federation\TrustMarkFetcher; use SimpleSAML\OpenID\Federation\TrustMarkStatusResponseFetcher; @@ -80,6 +88,13 @@ #[UsesClass(TrustMarkFetcher::class)] #[UsesClass(TrustMarkStatusResponseFetcher::class)] #[UsesClass(KeyPairResolver::class)] +#[UsesClass(CacheEntityCollectionStore::class)] +#[UsesClass(EntityCollectionFilter::class)] +#[UsesClass(EntityCollectionPaginator::class)] +#[UsesClass(EntityCollectionSorter::class)] +#[UsesClass(EntityCollectionFactory::class)] +#[UsesClass(FederationDiscovery::class)] +#[UsesClass(SubordinateListingFetcher::class)] final class FederationTest extends TestCase { protected \PHPUnit\Framework\MockObject\Stub $supportedAlgorithmsMock; @@ -169,5 +184,12 @@ public function testCanBuildTools(): void $this->assertInstanceOf(TrustMarkValidator::class, $sut->trustMarkValidator()); $this->assertInstanceOf(TrustMarkFetcher::class, $sut->trustMarkFetcher()); $this->assertInstanceOf(KeyPairResolver::class, $sut->keyPairResolver()); + $this->assertInstanceOf(SubordinateListingFetcher::class, $sut->subordinateListingFetcher()); + $this->assertInstanceOf(EntityCollectionStoreInterface::class, $sut->entityCollectionStore()); + $this->assertInstanceOf(EntityCollectionFactory::class, $sut->entityCollectionFactory()); + $this->assertInstanceOf(FederationDiscovery::class, $sut->federationDiscovery()); + $this->assertInstanceOf(EntityCollectionFilter::class, $sut->entityCollectionFilter()); + $this->assertInstanceOf(EntityCollectionSorter::class, $sut->entityCollectionSorter()); + $this->assertInstanceOf(EntityCollectionPaginator::class, $sut->entityCollectionPaginator()); } } diff --git a/tests/src/Helpers/ArrTest.php b/tests/src/Helpers/ArrTest.php index 9d29882..06b188a 100644 --- a/tests/src/Helpers/ArrTest.php +++ b/tests/src/Helpers/ArrTest.php @@ -161,4 +161,71 @@ public function testAddNestedValueThrowsForNonArrayPathElements(): void $arr = ['a' => 'b']; $this->sut()->addNestedValue($arr, 'c', 'a'); } + + + public function testIsAssociative(): void + { + $this->assertFalse($this->sut()->isAssociative([])); + $this->assertFalse($this->sut()->isAssociative(['a', 'b', 'c'])); + $this->assertTrue($this->sut()->isAssociative(['a' => 'b'])); + $this->assertTrue($this->sut()->isAssociative([1 => 'b'])); // Not sequential from 0 + $this->assertTrue($this->sut()->isAssociative([0 => 'a', 2 => 'b'])); + } + + + public function testIsOfArrays(): void + { + $this->assertTrue($this->sut()->isOfArrays([])); + $this->assertTrue($this->sut()->isOfArrays([[], [1]])); + $this->assertFalse($this->sut()->isOfArrays([[], 'a'])); + } + + + public function testContainsKey(): void + { + $arr = ['a' => ['b' => ['c' => 'd']], 'e' => 'f']; + $this->assertTrue($this->sut()->containsKey($arr, 'a')); + $this->assertTrue($this->sut()->containsKey($arr, 'b')); + $this->assertTrue($this->sut()->containsKey($arr, 'c')); + $this->assertTrue($this->sut()->containsKey($arr, 'e')); + $this->assertFalse($this->sut()->containsKey($arr, 'd')); + $this->assertFalse($this->sut()->containsKey($arr, 'f')); + $this->assertFalse($this->sut()->containsKey($arr, 'g')); + } + + + public function testHybridSort(): void + { + // Numeric keys + $arr = [3, 1, 2]; + $this->sut()->hybridSort($arr); + $this->assertSame([1, 2, 3], $arr); + + // String keys + $arr = ['b' => 2, 'a' => 1, 'c' => 3]; + $this->sut()->hybridSort($arr); + $this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $arr); + + // Nested + $arr = [ + 'b' => [3, 1, 2], + 'a' => ['y' => 2, 'x' => 1], + ]; + $this->sut()->hybridSort($arr); + $this->assertSame([ + 'a' => ['x' => 1, 'y' => 2], + 'b' => [1, 2, 3], + ], $arr); + } + + + public function testValidateMaxDepth(): void + { + $this->sut()->validateMaxDepth(Arr::MAX_DEPTH); + $this->assertTrue(true); + + $this->expectException(OpenIdException::class); + $this->expectExceptionMessage('Refusing to recurse'); + $this->sut()->validateMaxDepth(Arr::MAX_DEPTH + 1); + } }