Skip to content

Commit d416ec3

Browse files
authored
Enable entity collection (#26)
1 parent c9e6b39 commit d416ec3

35 files changed

Lines changed: 3704 additions & 82 deletions

docs/1-openid.md

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

33
1. [Installation](2-installation.md)
44
2. [OpenID Federation Tools](3-federation.md)
5+
2.1 [Federation Discovery and Entity Collection](3.1-federation-discovery.md)
56
3. [OpenID for Verifiable Credential Issuance (OpenID4VCI) Tools](4-vci.md)

docs/3-federation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# OpenID Federation Tools (draft 47)
1+
# OpenID Federation Tools
22

33
To use it, create an instance of the class `\SimpleSAML\OpenID\Federation`.
44

docs/3.1-federation-discovery.md

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# Federation Discovery and Entity Collection
2+
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.
6+
7+
The functionality is split into two main operational modes:
8+
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.
14+
15+
All components are integrated and accessible through the
16+
`\SimpleSAML\OpenID\Federation` facade.
17+
18+
---
19+
20+
## Setup and Configuration
21+
22+
To enable federation discovery, initialize the `Federation` facade with a
23+
cache and (optionally) a logger.
24+
25+
```php
26+
<?php
27+
28+
declare(strict_types=1);
29+
30+
use SimpleSAML\OpenID\Federation;
31+
32+
$federationTools = new Federation(
33+
cache: $cache, // \Psr\SimpleCache\CacheInterface (Highly Recommended)
34+
logger: $logger, // \Psr\Log\LoggerInterface
35+
maxDiscoveryDepth: 10, // Recursion limit for top-down traversal
36+
);
37+
```
38+
39+
### Custom Entity Collection Store
40+
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).
44+
45+
For production environments requiring persistent storage
46+
(e.g., Database, Redis), you should implement the `EntityCollectionStoreInterface`:
47+
48+
```php
49+
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface;
50+
51+
class MyDatabaseStore implements EntityCollectionStoreInterface
52+
{
53+
public function store(string $trustAnchorId, array $entities, int $ttl): void { /* ... */ }
54+
public function get(string $trustAnchorId): ?array { /* ... */ }
55+
public function clear(string $trustAnchorId): void { /* ... */ }
56+
public function storeLastUpdated(string $trustAnchorId, int $timestamp, int $ttl): void { /* ... */ }
57+
public function getLastUpdated(string $trustAnchorId): ?int { /* ... */ }
58+
public function clearLastUpdated(string $trustAnchorId): void { /* ... */ }
59+
}
60+
61+
$federationTools = new Federation(
62+
entityCollectionStore: new MyDatabaseStore(),
63+
);
64+
```
65+
66+
> [!NOTE]
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.
70+
71+
---
72+
73+
## Federation Discovery (Top-Down)
74+
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.
78+
79+
### Discovering Entities
80+
81+
```php
82+
/** @var \SimpleSAML\OpenID\Federation $federationTools */
83+
84+
$trustAnchorId = 'https://trust-anchor.example.org/';
85+
86+
try {
87+
// Traverse the federation and return an EntityCollection object.
88+
$collection = $federationTools->federationDiscovery()->discover($trustAnchorId);
89+
90+
// Get the raw map of Entity ID => Payload
91+
$entities = $collection->getEntities();
92+
93+
// Convenience: Get just the discovered entity IDs
94+
$ids = $federationTools->federationDiscovery()->discoverEntityIds($trustAnchorId);
95+
} catch (\Throwable $exception) {
96+
$logger->error('Federation discovery failed: ' . $exception->getMessage());
97+
}
98+
```
99+
100+
### Discovery Logic & Loop Protection
101+
102+
1. **Trust Anchor Config**: Fetches and validates the TA's Entity Configuration.
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.
111+
112+
### Applying Filters During Discovery
113+
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:
117+
118+
```php
119+
$collection = $federationTools->federationDiscovery()
120+
->discover($trustAnchorId, filters: ['entity_type' => 'openid_provider']);
121+
```
122+
123+
### Performance: Scheduled Refresh
124+
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:
127+
128+
```php
129+
// In a background job:
130+
$federationTools->federationDiscovery()
131+
->discover($trustAnchorId, forceRefresh: true);
132+
133+
// In your web application (uses cache):
134+
$collection = $federationTools->federationDiscovery()->discover($trustAnchorId);
135+
```
136+
137+
---
138+
139+
## Entity Collection Client
140+
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.
144+
145+
### Bulk Fetching with Filters
146+
147+
The client supports all standard OpenID Federation query parameters:
148+
149+
```php
150+
$endpoint = 'https://federation.example.org/collection';
151+
152+
$collection = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint(
153+
$endpoint,
154+
[
155+
'entity_type' => ['openid_provider'],
156+
'trust_mark_type' => ['https://example.org/marks/certified'],
157+
'trust_anchor' => 'https://trust-anchor.example.org/',
158+
'query' => 'university',
159+
'limit' => 50,
160+
'entity_claims' => ['entity_types', 'ui_infos'], // Request specific claims
161+
]
162+
);
163+
164+
foreach ($collection->getEntities() as $id => $payload) {
165+
// Process entity...
166+
}
167+
```
168+
169+
### Client-Side Caching
170+
171+
`fetchFromCollectionEndpoint()` automatically caches the remote response
172+
body. If you need fresh data, pass `forceRefresh: true`.
173+
174+
### Pagination Handling
175+
176+
The `EntityCollection` object encapsulates the `next` cursor for seamless
177+
pagination:
178+
179+
```php
180+
$results = [];
181+
$cursor = null;
182+
183+
do {
184+
$page = $federationTools->federationDiscovery()->fetchFromCollectionEndpoint(
185+
$endpoint,
186+
['limit' => 100, 'from' => $cursor]
187+
);
188+
189+
$results = array_merge($results, $page->getEntities());
190+
$cursor = $page->getNextPageToken();
191+
} while ($cursor !== null);
192+
```
193+
194+
---
195+
196+
## Server-Side Implementation
197+
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.
201+
202+
### The Pipeline Pattern
203+
204+
The recommended implementation follows this pipeline:
205+
**Discover → Filter → Sort → Paginate → Serialize**.
206+
207+
```php
208+
public function __invoke(ServerRequestInterface $request): ResponseInterface
209+
{
210+
$params = $request->getQueryParams();
211+
212+
// 1. Load entities from the Federation traversal cache
213+
$collection = $this->federationTools->federationDiscovery()->discover($this->trustAnchorId);
214+
215+
// 2. Filter (Standard OpenID Federation criteria)
216+
// Supports 'entity_type' (OR), 'trust_mark_type' (AND), and 'query' (Search)
217+
$collection->filter($params);
218+
219+
// 3. Sort (By nested metadata claims)
220+
if (isset($params['sort_by'])) {
221+
$path = explode('.', $params['sort_by']); // e.g. "metadata.federation_entity.display_name"
222+
$collection->sort([$path], $params['sort_dir'] ?? 'asc');
223+
}
224+
225+
// 4. Paginate (Using opaque cursors)
226+
$collection->paginate(
227+
limit: (int) ($params['limit'] ?? 100),
228+
from: $params['from'] ?? null
229+
);
230+
231+
// 5. Serialize to spec-compliant array
232+
return new JsonResponse($collection->toCollectionEndpointResponseArray());
233+
}
234+
```
235+
236+
### Filtering Technical Details
237+
238+
| Criteria | Behavior | Fields Checked |
239+
| :--- | :--- | :--- |
240+
| `entity_type` | **OR** (Any match) | Metadata keys |
241+
| `trust_mark_type` | **AND** (All must match) | `trust_marks[].id` |
242+
| `query` | **Case-Insensitive** | `sub`, `display_name`, `organization_name` |
243+
244+
### Sorting Technical Details
245+
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:
249+
250+
```php
251+
$collection->sort([
252+
['metadata', 'openid_provider', 'display_name'], // Primary (Metadata)
253+
['metadata', 'federation_entity', 'display_name'], // Fallback 1 (Metadata)
254+
['sub'] // Fallback 2 (Entity ID root claim)
255+
], 'asc');
256+
```
257+
258+
---
259+
260+
## Serialized Response Format
261+
262+
The `toCollectionEndpointResponseArray()` method produces a structure compatible
263+
with the OpenID Federation specification:
264+
265+
```json
266+
{
267+
"entities": [
268+
{
269+
"entity_id": "https://idp.example.org/",
270+
"entity_types": ["openid_provider"],
271+
"ui_infos": {
272+
"openid_provider": {
273+
"display_name": "Example IDP"
274+
}
275+
},
276+
"trust_marks": [
277+
{ "id": "https://example.org/marks/certified", "trust_mark": "..." }
278+
]
279+
}
280+
],
281+
"next": "aHR0cHM6Ly9pZHAuZXhhbXBsZS5vcmcv",
282+
"last_updated": 1745410000
283+
}
284+
```

0 commit comments

Comments
 (0)