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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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,