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..13f2e0e --- /dev/null +++ b/docs/5-federation-discovery.md @@ -0,0 +1,421 @@ +# 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 the JWT payload arrays per Trust Anchor. +> Entity Configurations are fetched dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()` +> during the traversal process, which handles JWS-level caching and respects expiry. + +## Federation Discovery + +Federation Discovery 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 Entities + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$trustAnchorId = 'https://trust-anchor.example.org/'; + +try { + // Discover all entities (ID -> payload map) in the federation. + $entities = $federationTools->federationDiscovery() + ->discover($trustAnchorId); + + // $entities is an array keyed by entity ID, where values are JWT payload arrays: + // [ + // 'https://trust-anchor.example.org/' => ['iss' => '...', 'metadata' => [...]], + // ... + // ] +} catch (\Throwable $exception) { + $logger->error('Federation discovery failed: ' . $exception->getMessage()); +} +``` + +The discovery algorithm: + +1. Fetches the Entity Configuration of the Trust Anchor. +2. Extracts the `federation_list_endpoint` from its metadata. +3. Calls the subordinate listing endpoint to get immediate subordinate IDs. +4. For each subordinate, fetches its Entity Configuration and, if it has its own + `federation_list_endpoint`, recurses (up to `maxDiscoveryDepth`). +5. Deduplicates all collected entities. +6. Persists the entity payloads in the store with a TTL based on the Trust Anchor's + expiry and the configured `maxCacheDuration`. + +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 +$entities = $federationTools->federationDiscovery() + ->discover( + $trustAnchorId, + filters: ['entity_type' => 'openid_relying_party'], + ); +``` + +### Periodic Refresh (Cron / Background Jobs) + +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() + ->discover($trustAnchorId, forceRefresh: true); +``` + +When `forceRefresh` is `true`: + +- The full federation traversal is re-executed. +- The new entity payload map is stored. +- Entity Configurations that haven't expired in the JWS cache are served from + cache; only stale or new ones trigger network requests. + +## Entity Collection Client + +The Entity Collection Client fetches from a remote +`federation_collection_endpoint` and deserializes the response into typed +objects. + +### Fetching from a Remote Endpoint + +```php +/** @var \SimpleSAML\OpenID\Federation $federationTools */ + +$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() + ->discover($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|EntityCollectionEntry> + 20, // Limit (page size) + null, // Cursor from a previous response's 'next' value, or null +); + +$pageEntities = $paginated['entities']; // array +$nextCursor = $paginated['next']; // ?string — null when on the last page +``` + +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/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" diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index b99c6f1..1d4a581 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -173,6 +173,14 @@ enum ClaimsEnum: string case Expiration_Date = 'expirationDate'; + case Entities = 'entities'; + + case EntityId = 'entity_id'; + + case EntityTypes = 'entity_types'; + + case FederationCollectionEndpoint = 'federation_collection_endpoint'; + case FederationFetchEndpoint = 'federation_fetch_endpoint'; case FederationListEndpoint = 'federation_list_endpoint'; @@ -253,6 +261,8 @@ enum ClaimsEnum: string case Keys = 'keys'; + case LastUpdated = 'last_updated'; + case Length = 'length'; case Locale = 'locale'; @@ -272,6 +282,8 @@ enum ClaimsEnum: string case Name = 'name'; + case Next = 'next'; + case Nonce = 'nonce'; case NonceEndpoint = 'nonce_endpoint'; @@ -430,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/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,92 @@ public function trustMarkFetcher(): TrustMarkFetcher } + public function subordinateListingFetcher(): SubordinateListingFetcher + { + return $this->subordinateListingFetcher ??= new SubordinateListingFetcher( + $this->artifactFetcher(), + $this->helpers(), + $this->logger, + ); + } + + + public function entityCollectionStore(): EntityCollectionStoreInterface + { + 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(), + $this->entityCollectionStore(), + $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( + $this->helpers(), + ); + } + + + public function entityCollectionResponseFactory(): EntityCollectionResponseFactory + { + return $this->entityCollectionBuilder ??= new EntityCollectionResponseFactory( + $this->federationDiscovery(), + $this->entityCollectionFilter(), + $this->entityCollectionSorter(), + $this->entityCollectionPaginator(), + $this->entityCollectionStore(), + ); + } + + public function helpers(): Helpers { return $this->helpers ??= new Helpers(); diff --git a/src/Federation/EntityCollection.php b/src/Federation/EntityCollection.php new file mode 100644 index 0000000..512ea01 --- /dev/null +++ b/src/Federation/EntityCollection.php @@ -0,0 +1,25 @@ +> $entities Keyed by entity ID, value is JWT payload + */ + public function __construct( + 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..e38cd6d --- /dev/null +++ b/src/Federation/EntityCollection/CacheEntityCollectionStore.php @@ -0,0 +1,148 @@ +cacheDecorator->set( + $entities, + $ttl, + self::KEY_FEDERATED_ENTITIES, + $trustAnchorId, + ); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to store entities in cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'entities' => $entities, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @inheritDoc + */ + public function get(string $trustAnchorId): ?array + { + try { + $cached = $this->cacheDecorator->get(null, self::KEY_FEDERATED_ENTITIES, $trustAnchorId); + + if (!is_array($cached)) { + return null; + } + + /** @var array> $cached */ + return $cached; + } catch (Throwable $throwable) { + $this->logger?->error('Unable to retrieve entities from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + return null; + } + } + + + /** + * @inheritDoc + */ + public function clear(string $trustAnchorId): void + { + try { + $this->cacheDecorator->delete(self::KEY_FEDERATED_ENTITIES, $trustAnchorId); + } catch (Throwable $throwable) { + $this->logger?->error('Unable to clear entities from cache.', [ + 'trustAnchorId' => $trustAnchorId, + 'exception_message' => $throwable->getMessage(), + ]); + } + } + + + /** + * @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/EntityCollectionEntry.php b/src/Federation/EntityCollection/EntityCollectionEntry.php new file mode 100644 index 0000000..099875e --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionEntry.php @@ -0,0 +1,52 @@ +|null $uiInfos Logo, display name, etc. + * @param array>|null $trustMarks + */ + public function __construct( + public readonly string $entityId, + public readonly array $entityTypes, + public readonly ?array $uiInfos = null, + public readonly ?array $trustMarks = null, + ) { + } + + + /** + * @return array{ + * entity_id: non-empty-string, + * entity_types: non-empty-string[], + * ui_infos?: array, + * trust_marks?: array> + * } + */ + public function jsonSerialize(): array + { + $data = [ + ClaimsEnum::EntityId->value => $this->entityId, + ClaimsEnum::EntityTypes->value => $this->entityTypes, + ]; + + if (!is_null($this->uiInfos)) { + $data[ClaimsEnum::UiInfos->value] = $this->uiInfos; + } + + if (!is_null($this->trustMarks)) { + $data[ClaimsEnum::TrustMarks->value] = $this->trustMarks; + } + + return $data; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionFetcher.php b/src/Federation/EntityCollection/EntityCollectionFetcher.php new file mode 100644 index 0000000..260aa44 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionFetcher.php @@ -0,0 +1,102 @@ +helpers->url()->withMultiValueParams($endpointUri, $filters); + + $this->logger?->debug('Fetching entity collection.', ['uri' => $uri, 'filters' => $filters]); + + try { + $responseBody = $this->artifactFetcher->fromNetworkAsString($uri); + + $decoded = $this->helpers->json()->decode($responseBody); + + if ( + !is_array($decoded) || + !isset($decoded[ClaimsEnum::Entities->value]) || + !is_array($decoded[ClaimsEnum::Entities->value]) + ) { + throw new EntityDiscoveryException('Entity collection response is missing "entities" array.'); + } + + $entries = []; + foreach ($decoded[ClaimsEnum::Entities->value] as $entryData) { + if (!is_array($entryData)) { + continue; + } + + /** @var array|null $uiInfo */ + $uiInfo = is_array($entryData[ClaimsEnum::UiInfos->value] ?? null) ? + $entryData[ClaimsEnum::UiInfos->value] : + null; + /** @var array>|null $trustMarks */ + $trustMarks = is_array($entryData[ClaimsEnum::TrustMarks->value] ?? null) + ? $entryData[ClaimsEnum::TrustMarks->value] + : null; + + $entries[] = new EntityCollectionEntry( + $this->helpers->type()->ensureNonEmptyString($entryData[ClaimsEnum::Id->value] ?? null), + $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings( + $entryData[ClaimsEnum::EntityTypes->value] ?? [], + ClaimsEnum::EntityTypes->value, + ), + $uiInfo, + $trustMarks, + ); + } + + $next = is_string($next = $decoded[ClaimsEnum::Next->value] ?? null) ? $next : null; + $lastUpdated = is_numeric($lastUpdated = $decoded[ClaimsEnum::LastUpdated->value] ?? null) ? + $this->helpers->type()->ensureInt($lastUpdated) : + null; + + return new EntityCollectionResponse( + $entries, + $next, + $lastUpdated, + ); + } catch (Throwable $throwable) { + $message = sprintf('Unable to fetch entity collection from %s. Error: %s', $uri, $throwable->getMessage()); + $this->logger?->error($message); + throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable); + } + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionFilter.php b/src/Federation/EntityCollection/EntityCollectionFilter.php new file mode 100644 index 0000000..5359be0 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionFilter.php @@ -0,0 +1,132 @@ +> Filtered + * entity payloads keyed by entity ID + */ + public function filter(EntityCollection $entityCollection, array $criteria): array + { + $filtered = $entityCollection->all(); + + // 1. entity_type + if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) { + $types = $criteria['entity_type']; + $filtered = array_filter($filtered, function (array $payload) use ($types): bool { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { + return false; + } + + 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 (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; + } + } + } + + return false; + }); + } + + // 3. query + if (isset($criteria['query']) && $criteria['query'] !== '') { + $q = mb_strtolower($criteria['query']); + $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 = $payload[ClaimsEnum::Metadata->value] ?? null; + if (!is_array($metadata)) { + return false; + } + + // Check display_name or organization_name in any entity type + foreach ($metadata as $typePayload) { + if (!is_array($typePayload)) { + continue; + } + + $displayNameValue = $typePayload[ClaimsEnum::DisplayName->value] ?? ''; + $displayName = mb_strtolower(is_string($displayNameValue) ? $displayNameValue : ''); + if ($displayName !== '' && str_contains($displayName, $q)) { + return true; + } + + $orgNameValue = $typePayload[ClaimsEnum::OrganizationName->value] ?? ''; + $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 (array $payload) use ($ta): bool { + // In a top-down traversal, everything is subordinate to the TA we started with. + // If the collection contains multiple TAs, we would check authority_hints. + $hints = $this->helpers->arr()->getNestedValue( + $payload, + ClaimsEnum::AuthorityHints->value, + ); + if (is_array($hints)) { + return in_array($ta, $hints, true); + } + + return false; + }); + } + + return $filtered; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionPaginator.php b/src/Federation/EntityCollection/EntityCollectionPaginator.php new file mode 100644 index 0000000..729a145 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionPaginator.php @@ -0,0 +1,53 @@ + $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 = $this->helpers->base64Url()->decode($from); + $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); + if ($lastIdInPage !== null) { + $next = $this->helpers->base64Url()->encode((string)$lastIdInPage); + } + } + + return [ + ClaimsEnum::Entities->value => $pageItems, + ClaimsEnum::Next->value => $next, + ]; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionResponse.php b/src/Federation/EntityCollection/EntityCollectionResponse.php new file mode 100644 index 0000000..c281238 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionResponse.php @@ -0,0 +1,44 @@ +value => $this->entities, + ]; + + if (!is_null($this->next)) { + $data[ClaimsEnum::Next->value] = $this->next; + } + + if (!is_null($this->lastUpdated)) { + $data[ClaimsEnum::LastUpdated->value] = $this->lastUpdated; + } + + return $data; + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionResponseFactory.php b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php new file mode 100644 index 0000000..20694db --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionResponseFactory.php @@ -0,0 +1,124 @@ +federationDiscovery->discover($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 => $payload) { + $metadata = $payload[ClaimsEnum::Metadata->value] ?? []; + if (!is_array($metadata)) { + $metadata = []; + } + + /** @var non-empty-string[] $entityTypes */ + $entityTypes = array_keys($metadata); + + // ui_info projection + $uiInfo = null; + if (is_array($uiClaims) && $uiClaims !== []) { + $uiInfo = []; + foreach ($metadata as $typePayload) { + if (!is_array($typePayload)) { + continue; + } + + foreach ($uiClaims as $claim) { + if (isset($typePayload[$claim])) { + $uiInfo[$claim] = $typePayload[$claim]; + } + } + } + } + + // trust_marks projection is handled by getting them from statement + $trustMarks = null; + $marks = $payload[ClaimsEnum::TrustMarks->value] ?? null; + if (is_array($marks)) { + /** @var array> $marks */ + $trustMarks = $marks; + } + + // If entity_claims is provided, we might want to filter the metadata itself, + // but the EntityCollectionEntry DTO currently separates ui_info. + // For now, project into the Entry VO. + /** @var non-empty-string $id */ + $entries[$id] = new EntityCollectionEntry( + $id, + $entityTypes, + $uiInfo, + $trustMarks, + ); + } + + // 5. Paginate + $limit = isset($requestParams['limit']) ? (int)$requestParams['limit'] : 100; + $limit = max(1, $limit); + + $from = $requestParams['from'] ?? null; + + $paginated = $this->paginator->paginate($entries, $limit, $from); + + return new EntityCollectionResponse( + entities: array_values($paginated['entities']), + next: $paginated['next'], + lastUpdated: $this->entityCollectionStore->getLastUpdated($trustAnchorId) ?? time(), + ); + } +} diff --git a/src/Federation/EntityCollection/EntityCollectionSorter.php b/src/Federation/EntityCollection/EntityCollectionSorter.php new file mode 100644 index 0000000..78bc979 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionSorter.php @@ -0,0 +1,57 @@ +> $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 (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); + + // 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/EntityCollection/EntityCollectionStoreInterface.php b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php new file mode 100644 index 0000000..e8e9914 --- /dev/null +++ b/src/Federation/EntityCollection/EntityCollectionStoreInterface.php @@ -0,0 +1,54 @@ +> $entities Keyed by entity ID, value is JWT payload + */ + public function store(string $trustAnchorId, array $entities, int $ttl): void; + + + /** + * Retrieve previously discovered entities. + * + * @param non-empty-string $trustAnchorId + * @return array>|null null when not found / expired + */ + public function get(string $trustAnchorId): ?array; + + + /** + * Remove stored entities (force re-discovery). + * + * @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 new file mode 100644 index 0000000..13358c7 --- /dev/null +++ b/src/Federation/EntityCollection/InMemoryEntityCollectionStore.php @@ -0,0 +1,62 @@ +>, expires: int}> */ + protected array $store = []; + + /** @var array */ + protected array $lastUpdatedStore = []; + + + public function store(string $trustAnchorId, array $entities, int $ttl): void + { + $this->store[$trustAnchorId] = [ + 'entities' => $entities, + 'expires' => time() + $ttl, + ]; + } + + + public function get(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]['entities']; + } + + + 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]); + } +} diff --git a/src/Federation/EntityStatement.php b/src/Federation/EntityStatement.php index 7ba635e..84d4be1 100644 --- a/src/Federation/EntityStatement.php +++ b/src/Federation/EntityStatement.php @@ -386,6 +386,54 @@ public function getFederationTrustMarkStatusEndpoint(): ?string } + /** + * @return ?non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function getFederationListEndpoint(): ?string + { + $federationListEndpoint = $this->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..0a390b9 --- /dev/null +++ b/src/Federation/FederationDiscovery.php @@ -0,0 +1,165 @@ + 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 entities and + * re-traverse the federation + * @return array> + */ + public function discover( + string $trustAnchorId, + array $filters = [], + bool $forceRefresh = false, + ): array { + if (!$forceRefresh) { + $cachedEntities = $this->entityCollectionStore->get($trustAnchorId); + if (is_array($cachedEntities)) { + $this->logger?->debug( + 'Returning discovered entities from entity collection store.', + ['trustAnchorId' => $trustAnchorId], + ); + return $cachedEntities; + } + } + + $this->logger?->info( + 'Starting federation discovery.', + ['trustAnchorId' => $trustAnchorId, 'filters' => $filters], + ); + + $discoveredEntities = []; + try { + // Step 1: Fetch TA config + $taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId); + + // Recursive traversal + $discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters); + + // Compute TTL: lowest of maxCacheDuration and TA expiry + $ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime( + $taConfig->getExpirationTime(), + ); + + $this->entityCollectionStore->store($trustAnchorId, $discoveredEntities, $ttl); + $this->entityCollectionStore->storeLastUpdated($trustAnchorId, time(), $ttl); + + $this->logger?->info('Federation discovery completed.', [ + 'trustAnchorId' => $trustAnchorId, + 'discoveredCount' => count($discoveredEntities), + ]); + } catch (Throwable $throwable) { + $this->logger?->error('Federation discovery failed.', [ + 'trustAnchorId' => $trustAnchorId, + 'error' => $throwable->getMessage(), + ]); + } + + 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)); + } + + + /** + * @param non-empty-string $entityId + * @param array $filters + * @param string[] $visited + * @return array> + */ + 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; + $allCollectedEntities = [$entityId => $entityConfig->getPayload()]; + + $listEndpoint = $entityConfig->getFederationListEndpoint(); + if (is_null($listEndpoint)) { + 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); + $allCollectedEntities = array_merge( + $allCollectedEntities, + $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, but with an empty payload + if (!isset($allCollectedEntities[$subId])) { + $allCollectedEntities[$subId] = []; + } + } + } + } catch (Throwable $throwable) { + $this->logger?->error('Failed to fetch subordinate listing during discovery.', [ + 'entityId' => $entityId, + 'error' => $throwable->getMessage(), + ]); + } + + return $allCollectedEntities; + } +} diff --git a/src/Federation/SubordinateListingFetcher.php b/src/Federation/SubordinateListingFetcher.php new file mode 100644 index 0000000..aa59008 --- /dev/null +++ b/src/Federation/SubordinateListingFetcher.php @@ -0,0 +1,60 @@ + $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]); + + $decoded = $this->helpers->json()->decode($responseBody); + + if (!is_array($decoded)) { + throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.'); + } + + return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, 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/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);