Skip to content

Commit 53802b2

Browse files
committed
WIP
1 parent 2b32831 commit 53802b2

6 files changed

Lines changed: 266 additions & 42 deletions

File tree

docs/1-openid.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
1. [Installation](2-installation.md)
44
2. [OpenID Federation Tools](3-federation.md)
5+
2.1 [Federation Discovery](3.1-federation-discovery.md)
56
3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md)
67
4. [Federation Discovery and Entity Collection](5-federation-discovery.md)
Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
# Federation Discovery and Entity Collection
22

3-
This library provides a high-performance, specification-compliant toolkit for discovering entities within an OpenID Federation and interacting with Entity Collection Endpoints.
3+
This library provides a high-performance, specification-compliant toolkit for
4+
discovering entities within an OpenID Federation and interacting with Entity
5+
Collection Endpoints.
46

57
The functionality is split into two main operational modes:
68

7-
1. **Federation Discovery** — A top-down, recursive traversal of a federation hierarchy starting from a Trust Anchor.
8-
2. **Entity Collection** — A specialized protocol for optimized bulk-fetching of entities, featuring support for server-side filtering, sorting, and cursor-based pagination.
9+
1. **Federation Discovery** — A top-down, recursive traversal of a federation
10+
hierarchy starting from a Trust Anchor.
11+
2. **Entity Collection** — A specialized protocol for optimized bulk-fetching
12+
of entities, featuring support for server-side filtering, sorting, and
13+
cursor-based pagination.
914

10-
All components are integrated and accessible through the `\SimpleSAML\OpenID\Federation` facade.
15+
All components are integrated and accessible through the
16+
`\SimpleSAML\OpenID\Federation` facade.
1117

1218
---
1319

1420
## Setup and Configuration
1521

16-
To enable federation discovery, initialize the `Federation` facade with a cache and (optionally) a logger.
22+
To enable federation discovery, initialize the `Federation` facade with a
23+
cache and (optionally) a logger.
1724

1825
```php
1926
<?php
@@ -31,9 +38,12 @@ $federationTools = new Federation(
3138

3239
### Custom Entity Collection Store
3340

34-
By default, the library persists discovered entity payloads using the configured PSR-16 cache (`CacheEntityCollectionStore`). If no cache is provided, it falls back to an `InMemoryEntityCollectionStore` (ephemeral).
41+
By default, the library persists discovered entity payloads using the
42+
configured PSR-16 cache (`CacheEntityCollectionStore`). If no cache is
43+
provided, it falls back to an `InMemoryEntityCollectionStore` (ephemeral).
3544

36-
For production environments requiring persistent storage (e.g., Database, Redis), you should implement the `EntityCollectionStoreInterface`:
45+
For production environments requiring persistent storage
46+
(e.g., Database, Redis), you should implement the `EntityCollectionStoreInterface`:
3747

3848
```php
3949
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface;
@@ -43,8 +53,6 @@ class MyDatabaseStore implements EntityCollectionStoreInterface
4353
public function store(string $trustAnchorId, array $entities, int $ttl): void { /* ... */ }
4454
public function get(string $trustAnchorId): ?array { /* ... */ }
4555
public function clear(string $trustAnchorId): void { /* ... */ }
46-
47-
// New in v2.0: Track last update time for 'last_updated' response field
4856
public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void { /* ... */ }
4957
public function getLastUpdated(string $trustAnchorId): ?int { /* ... */ }
5058
public function clearLastUpdated(string $trustAnchorId): void { /* ... */ }
@@ -56,13 +64,17 @@ $federationTools = new Federation(
5664
```
5765

5866
> [!NOTE]
59-
> The store caches the **JWT payload arrays** of discovered entities. Actual JWS signatures and original JWT strings are managed by the `EntityStatementFetcher` which handles its own caching and validation logic.
67+
> The store caches the **JWT payload arrays** of discovered entities. Actual
68+
> JWS signatures and original JWT strings are managed by the
69+
> `EntityStatementFetcher` which handles its own caching and validation logic.
6070
6171
---
6272

6373
## Federation Discovery (Top-Down)
6474

65-
Federation Discovery performs a recursive traversal of the hierarchy. It starts at the Trust Anchor and follows `federation_list_endpoint` links to discover all subordinates.
75+
Federation Discovery performs a recursive traversal of the hierarchy.
76+
It starts at the Trust Anchor and follows `federation_list_endpoint` links
77+
to discover all subordinates.
6678

6779
### Discovering Entities
6880

@@ -88,14 +100,20 @@ try {
88100
### Discovery Logic & Loop Protection
89101

90102
1. **Trust Anchor Config**: Fetches and validates the TA's Entity Configuration.
91-
2. **Subordinate Listing**: Fetches the `federation_list_endpoint`. If filters are provided, they are passed as query parameters to this endpoint.
92-
3. **Recursion**: For each discovered subordinate, it fetches its configuration and repeats the process.
93-
4. **Loop Protection**: The algorithm tracks visited IDs to prevent infinite loops and is limited by `maxDiscoveryDepth`.
94-
5. **Deduplication**: Entities appearing in multiple branches are only stored once.
103+
2. **Subordinate Listing**: Fetches the `federation_list_endpoint`.
104+
If filters are provided, they are passed as query parameters to this endpoint.
105+
3. **Recursion**: For each discovered subordinate, it fetches its
106+
configuration and repeats the process.
107+
4. **Loop Protection**: The algorithm tracks visited IDs to prevent
108+
infinite loops and is limited by `maxDiscoveryDepth`.
109+
5. **Deduplication**: Entities appearing in multiple branches are only stored
110+
once.
95111

96112
### Applying Filters During Discovery
97113

98-
You can pass filters (like `entity_type`) directly to the discovery process. These are passed to the remote `federation_list_endpoint` to optimize the traversal:
114+
You can pass filters (like `entity_type`) directly to the discovery process.
115+
These are passed to the remote `federation_list_endpoint` to optimize the
116+
traversal:
99117

100118
```php
101119
$collection = $federationTools->federationDiscovery()
@@ -104,7 +122,8 @@ $collection = $federationTools->federationDiscovery()
104122

105123
### Performance: Scheduled Refresh
106124

107-
Discovery is an expensive network-heavy operation. You should run it in a background process (Cron) using `forceRefresh: true` to populate the cache:
125+
Discovery is an expensive network-heavy operation. You should run it in a
126+
background process (Cron) using `forceRefresh: true` to populate the cache:
108127

109128
```php
110129
// In a background job:
@@ -119,7 +138,9 @@ $collection = $federationTools->federationDiscovery()->discover($trustAnchorId);
119138

120139
## Entity Collection Client
121140

122-
The Entity Collection Client allows fetching pre-filtered lists of entities from a remote `federation_collection_endpoint`. This is much more efficient than full traversal if the remote side supports it.
141+
The Entity Collection Client allows fetching pre-filtered lists of entities
142+
from a remote `federation_collection_endpoint`. This is much more efficient
143+
than full traversal if the remote side supports it.
123144

124145
### Bulk Fetching with Filters
125146

@@ -128,15 +149,15 @@ The client supports all standard OpenID Federation query parameters:
128149
```php
129150
$endpoint = 'https://federation.example.org/collection';
130151

131-
$collection = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint(
152+
$collection = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint(
132153
$endpoint,
133154
[
134155
'entity_type' => ['openid_provider'],
135156
'trust_mark_type' => ['https://example.org/marks/certified'],
136157
'trust_anchor' => 'https://trust-anchor.example.org/',
137158
'query' => 'university',
138159
'limit' => 50,
139-
'entity_claims' => ['display_name', 'contacts'], // Request specific claims
160+
'entity_claims' => ['entity_types', 'ui_infos'], // Request specific claims
140161
]
141162
);
142163

@@ -147,18 +168,20 @@ foreach ($collection->getEntities() as $id => $payload) {
147168

148169
### Client-Side Caching
149170

150-
`discoverFromCollectionEndpoint()` automatically caches the remote response body. If you need fresh data, pass `forceRefresh: true`.
171+
`fetchFromCollectionEndpoint()` automatically caches the remote response
172+
body. If you need fresh data, pass `forceRefresh: true`.
151173

152174
### Pagination Handling
153175

154-
The `EntityCollection` object encapsulates the `next` cursor for seamless pagination:
176+
The `EntityCollection` object encapsulates the `next` cursor for seamless
177+
pagination:
155178

156179
```php
157180
$results = [];
158181
$cursor = null;
159182

160183
do {
161-
$page = $federationTools->federationDiscovery()->discoverFromCollectionEndpoint(
184+
$page = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint(
162185
$endpoint,
163186
['limit' => 100, 'from' => $cursor]
164187
);
@@ -172,11 +195,14 @@ do {
172195

173196
## Server-Side Implementation
174197

175-
If you are implementing your own `federation_collection_endpoint`, the library provides high-level building blocks to handle filtering, sorting, and pagination.
198+
If you are implementing your own `federation_collection_endpoint`, the library
199+
provides high-level building blocks to handle filtering, sorting, and
200+
pagination.
176201

177202
### The Pipeline Pattern
178203

179-
The recommended implementation follows this pipeline: **Discover → Filter → Sort → Paginate → Serialize**.
204+
The recommended implementation follows this pipeline:
205+
**Discover → Filter → Sort → Paginate → Serialize**.
180206

181207
```php
182208
public function __invoke(ServerRequestInterface $request): ResponseInterface
@@ -192,7 +218,7 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
192218

193219
// 3. Sort (By nested metadata claims)
194220
if (isset($params['sort_by'])) {
195-
$path = explode('.', $params['sort_by']); // e.g. "federation_entity.display_name"
221+
$path = explode('.', $params['sort_by']); // e.g. "metadata.federation_entity.display_name"
196222
$collection->sort([$path], $params['sort_dir'] ?? 'asc');
197223
}
198224

@@ -217,7 +243,9 @@ public function __invoke(ServerRequestInterface $request): ResponseInterface
217243

218244
### Sorting Technical Details
219245

220-
The `sort()` method accepts an array of claim paths relative to the **JWT payload root**. When sorting by metadata claims, you must explicitly include the `metadata` prefix:
246+
The `sort()` method accepts an array of claim paths relative to the
247+
**JWT payload root**. When sorting by metadata claims, you must explicitly
248+
include the `metadata` prefix:
221249

222250
```php
223251
$collection->sort([
@@ -231,7 +259,8 @@ $collection->sort([
231259

232260
## Serialized Response Format
233261

234-
The `toCollectionEndpointResponseArray()` method produces a structure compatible with the OpenID Federation specification:
262+
The `toCollectionEndpointResponseArray()` method produces a structure compatible
263+
with the OpenID Federation specification:
235264

236265
```json
237266
{

src/Federation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ public function subordinateListingFetcher(): SubordinateListingFetcher
352352
return $this->subordinateListingFetcher ??= new SubordinateListingFetcher(
353353
$this->artifactFetcher(),
354354
$this->helpers(),
355+
$this->maxCacheDurationDecorator(),
355356
$this->logger,
356357
);
357358
}

src/Federation/FederationDiscovery.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public function discover(
7171
$taConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($trustAnchorId);
7272

7373
// Recursive traversal
74-
$discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters);
74+
$discoveredEntities = $this->traverse($trustAnchorId, $taConfig, $filters, 0, [], $forceRefresh);
7575

7676
// Compute TTL: lowest of maxCacheDuration and TA expiry
7777
$ttl = $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime(
@@ -115,7 +115,7 @@ public function discover(
115115
* } $filters
116116
* @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException
117117
*/
118-
public function discoverFromCollectionEndpoint(
118+
public function fetchFromCollectionEndpoint(
119119
string $endpointUri,
120120
array $filters = [],
121121
bool $forceRefresh = false,
@@ -157,7 +157,7 @@ public function discoverFromCollectionEndpoint(
157157
}
158158

159159

160-
private function buildEntityCollectionFromResponse(string $responseBody): EntityCollection
160+
protected function buildEntityCollectionFromResponse(string $responseBody): EntityCollection
161161
{
162162
$decoded = $this->helpers->json()->decode($responseBody);
163163

@@ -237,12 +237,13 @@ public function discoverEntityIds(
237237
* @param string[] $visited
238238
* @return array<string, array<string, mixed>>
239239
*/
240-
private function traverse(
240+
protected function traverse(
241241
string $entityId,
242242
EntityStatement $entityConfig,
243243
array $filters,
244244
int $depth = 0,
245245
array $visited = [],
246+
bool $forceRefresh = false,
246247
): array {
247248
if ($depth > $this->maxDepth || in_array($entityId, $visited, true)) {
248249
return [];
@@ -257,7 +258,7 @@ private function traverse(
257258
}
258259

259260
try {
260-
$subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters);
261+
$subordinateIds = $this->subordinateListingFetcher->fetch($listEndpoint, $filters, $forceRefresh);
261262

262263
foreach ($subordinateIds as $subId) {
263264
// If we've already visited this subId (loop), skip to avoid infinite recursion
@@ -269,7 +270,7 @@ private function traverse(
269270
$subConfig = $this->entityStatementFetcher->fromCacheOrWellKnownEndpoint($subId);
270271
$allCollectedEntities = array_merge(
271272
$allCollectedEntities,
272-
$this->traverse($subId, $subConfig, $filters, $depth + 1, $visited),
273+
$this->traverse($subId, $subConfig, $filters, $depth + 1, $visited, $forceRefresh),
273274
);
274275
} catch (Throwable $e) {
275276
$this->logger?->warning('Failed to fetch subordinate configuration during discovery.', [

src/Federation/SubordinateListingFetcher.php

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
namespace SimpleSAML\OpenID\Federation;
66

77
use Psr\Log\LoggerInterface;
8-
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
8+
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
99
use SimpleSAML\OpenID\Exceptions\EntityDiscoveryException;
1010
use SimpleSAML\OpenID\Helpers;
1111
use SimpleSAML\OpenID\Utils\ArtifactFetcher;
@@ -16,6 +16,7 @@ class SubordinateListingFetcher
1616
public function __construct(
1717
protected readonly ArtifactFetcher $artifactFetcher,
1818
protected readonly Helpers $helpers,
19+
protected readonly DateIntervalDecorator $maxCacheDurationDecorator,
1920
protected readonly ?LoggerInterface $logger = null,
2021
) {
2122
}
@@ -26,27 +27,41 @@ public function __construct(
2627
*
2728
* @param non-empty-string $listEndpointUri
2829
* @param array<string, string|string[]> $filters Optional query params: entity_type, intermediate, etc.
30+
* @param bool $forceRefresh If true, ignore cached listing and fetch from network.
2931
* @return non-empty-string[]
3032
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
3133
* @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException
3234
*/
33-
public function fetch(string $listEndpointUri, array $filters = []): array
35+
public function fetch(string $listEndpointUri, array $filters = [], bool $forceRefresh = false): array
3436
{
3537
$uri = $this->helpers->url()->withMultiValueParams($listEndpointUri, $filters);
3638

37-
$this->logger?->debug('Fetching subordinate listing.', ['uri' => $uri, 'filters' => $filters]);
39+
if (!$forceRefresh) {
40+
$this->logger?->debug('Checking for cached subordinate listing.', ['uri' => $uri]);
41+
$cached = $this->artifactFetcher->fromCacheAsString($uri);
42+
if (is_string($cached)) {
43+
$this->logger?->debug('Returning cached subordinate listing.', ['uri' => $uri]);
44+
return $this->decodeAndEnsureType($cached);
45+
}
46+
47+
$this->logger?->debug('No cached subordinate listing found.', ['uri' => $uri]);
48+
}
49+
50+
$this->logger?->debug('Fetching subordinate listing from network.', ['uri' => $uri, 'filters' => $filters]);
3851

3952
try {
4053
$responseBody = $this->artifactFetcher->fromNetworkAsString($uri);
4154
$this->logger?->debug('Fetched subordinate listing from network.', ['uri' => $uri]);
4255

43-
$decoded = $this->helpers->json()->decode($responseBody);
56+
$result = $this->decodeAndEnsureType($responseBody);
4457

45-
if (!is_array($decoded)) {
46-
throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.');
47-
}
58+
$this->artifactFetcher->cacheIt(
59+
$responseBody,
60+
$this->maxCacheDurationDecorator->getInSeconds(),
61+
$uri,
62+
);
4863

49-
return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, ClaimsEnum::Sub->value);
64+
return $result;
5065
} catch (Throwable $throwable) {
5166
$message = sprintf(
5267
'Unable to fetch subordinate listing from %s. Error: %s',
@@ -57,4 +72,20 @@ public function fetch(string $listEndpointUri, array $filters = []): array
5772
throw new EntityDiscoveryException($message, (int)$throwable->getCode(), $throwable);
5873
}
5974
}
75+
76+
77+
/**
78+
* @return non-empty-string[]
79+
* @throws \SimpleSAML\OpenID\Exceptions\EntityDiscoveryException
80+
*/
81+
protected function decodeAndEnsureType(string $responseBody): array
82+
{
83+
$decoded = $this->helpers->json()->decode($responseBody);
84+
85+
if (!is_array($decoded)) {
86+
throw new EntityDiscoveryException('Subordinate listing response is not a JSON array.');
87+
}
88+
89+
return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded, 'Subordinate Listing');
90+
}
6091
}

0 commit comments

Comments
 (0)