Skip to content

Commit 6e90dd7

Browse files
committed
WIP
1 parent f37c39c commit 6e90dd7

9 files changed

Lines changed: 167 additions & 183 deletions

docs/5-federation-discovery.md

Lines changed: 36 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -61,48 +61,50 @@ The store interface is minimal:
6161
interface EntityCollectionStoreInterface
6262
{
6363
/**
64-
* Persist discovered entity IDs for a given Trust Anchor.
64+
* Persist discovered entities for a given Trust Anchor.
6565
*/
66-
public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void;
66+
public function store(string $trustAnchorId, array $entities, int $ttl): void;
6767

6868
/**
69-
* Retrieve previously discovered entity IDs.
69+
* Retrieve previously discovered entities.
7070
* Return null when not found or expired.
7171
*/
72-
public function getEntityIds(string $trustAnchorId): ?array;
72+
public function get(string $trustAnchorId): ?array;
7373

7474
/**
75-
* Remove stored entity IDs (for force re-discovery).
75+
* Remove stored entities (for force re-discovery).
7676
*/
77-
public function clearEntityIds(string $trustAnchorId): void;
77+
public function clear(string $trustAnchorId): void;
7878
}
7979
```
8080

81-
> **Note**: The store tracks only the list of entity IDs per Trust Anchor, not
82-
> the Entity Configurations themselves. Entity Configurations are fetched
83-
> dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()`,
84-
> which already handles JWS-level caching and respects expiry.
81+
> **Note**: The store tracks the JWT payload arrays per Trust Anchor.
82+
> Entity Configurations are fetched dynamically through `EntityStatementFetcher::fromCacheOrWellKnownEndpoint()`
83+
> during the traversal process, which handles JWS-level caching and respects expiry.
8584
8685
## Federation Discovery
8786

8887
Federation Discovery performs a top-down traversal of the federation hierarchy.
8988
Starting from a Trust Anchor, it follows `federation_list_endpoint` links on
9089
each entity to collect all subordinate entity IDs recursively.
9190

92-
### Discovering Entity IDs
91+
### Discovering Entities
9392

9493
```php
9594
/** @var \SimpleSAML\OpenID\Federation $federationTools */
9695

9796
$trustAnchorId = 'https://trust-anchor.example.org/';
9897

9998
try {
100-
// Discover all entity IDs in the federation.
101-
$entityIds = $federationTools->federationDiscovery()
102-
->discoverEntities($trustAnchorId);
99+
// Discover all entities (ID -> payload map) in the federation.
100+
$entities = $federationTools->federationDiscovery()
101+
->discover($trustAnchorId);
103102

104-
// $entityIds is an array of entity ID strings, e.g.:
105-
// ['https://trust-anchor.example.org/', 'https://intermediate.example.org/', ...]
103+
// $entities is an array keyed by entity ID, where values are JWT payload arrays:
104+
// [
105+
// 'https://trust-anchor.example.org/' => ['iss' => '...', 'metadata' => [...]],
106+
// ...
107+
// ]
106108
} catch (\Throwable $exception) {
107109
$logger->error('Federation discovery failed: ' . $exception->getMessage());
108110
}
@@ -115,63 +117,46 @@ The discovery algorithm:
115117
3. Calls the subordinate listing endpoint to get immediate subordinate IDs.
116118
4. For each subordinate, fetches its Entity Configuration and, if it has its own
117119
`federation_list_endpoint`, recurses (up to `maxDiscoveryDepth`).
118-
5. Deduplicates all collected entity IDs.
119-
6. Persists the ID list in the store with a TTL based on the Trust Anchor's
120+
5. Deduplicates all collected entities.
121+
6. Persists the entity payloads in the store with a TTL based on the Trust Anchor's
120122
expiry and the configured `maxCacheDuration`.
121123

124+
If you only need the list of entity IDs without their payloads, use the convenience method:
125+
126+
```php
127+
$entityIds = $federationTools->federationDiscovery()
128+
->discoverEntityIds($trustAnchorId);
129+
```
130+
122131
### Applying Filters During Discovery
123132

124133
You can pass filter parameters (e.g. `entity_type`) to the subordinate listing
125134
endpoint:
126135

127136
```php
128-
$entityIds = $federationTools->federationDiscovery()
129-
->discoverEntities(
137+
$entities = $federationTools->federationDiscovery()
138+
->discover(
130139
$trustAnchorId,
131140
filters: ['entity_type' => 'openid_relying_party'],
132141
);
133142
```
134143

135-
### Discovering and Fetching Entity Configurations
136-
137-
The convenience method `discoverAndFetch()` performs discovery and then fetches
138-
the Entity Configuration for each discovered entity:
139-
140-
```php
141-
try {
142-
// Returns array<string, EntityStatement> keyed by entity ID.
143-
$entities = $federationTools->federationDiscovery()
144-
->discoverAndFetch($trustAnchorId);
145-
146-
foreach ($entities as $entityId => $entityStatement) {
147-
$metadata = $entityStatement->getMetadata();
148-
// ...
149-
}
150-
} catch (\Throwable $exception) {
151-
$logger->error('Discovery failed: ' . $exception->getMessage());
152-
}
153-
```
154-
155-
> **Note**: Entity Configurations are fetched through the existing
156-
> `EntityStatementFetcher`, which caches JWS at the network level. If a cached
157-
> configuration has expired, a fresh one is fetched automatically.
158-
159144
### Periodic Refresh (Cron / Background Jobs)
160145

161-
Use the `forceRefresh` parameter to clear the stored entity ID list and
146+
Use the `forceRefresh` parameter to clear the stored entities and
162147
re-traverse the federation. This is the intended pattern for cron or background
163148
refresh jobs:
164149

165150
```php
166151
// In a scheduled task / cron job:
167152
$federationTools->federationDiscovery()
168-
->discoverAndFetch($trustAnchorId, forceRefresh: true);
153+
->discover($trustAnchorId, forceRefresh: true);
169154
```
170155

171156
When `forceRefresh` is `true`:
172157

173158
- The full federation traversal is re-executed.
174-
- The new entity ID list is stored.
159+
- The new entity payload map is stored.
175160
- Entity Configurations that haven't expired in the JWS cache are served from
176161
cache; only stale or new ones trigger network requests.
177162

@@ -309,7 +294,7 @@ use SimpleSAML\OpenID\Federation\EntityCollection;
309294

310295
// Prepare a collection from discovery or any other source.
311296
$entities = $federationTools->federationDiscovery()
312-
->discoverAndFetch($trustAnchorId);
297+
->discover($trustAnchorId);
313298
$collection = new EntityCollection($entities);
314299

315300
// Filter by entity type and text query.
@@ -321,7 +306,7 @@ $filtered = $federationTools->entityCollectionFilter()->filter(
321306
],
322307
);
323308

324-
// $filtered is array<string, EntityStatement> keyed by entity ID.
309+
// $filtered is array<string, array<string, mixed>> keyed by entity ID.
325310
```
326311

327312
#### EntityCollectionSorter
@@ -333,7 +318,7 @@ Sorts entities by a metadata claim value:
333318

334319
// Sort by display_name under the federation_entity metadata.
335320
$sorted = $federationTools->entityCollectionSorter()->sortByMetadataClaim(
336-
$filtered, // array<string, EntityStatement>
321+
$filtered, // array<string, array<string, mixed>>
337322
['federation_entity', 'display_name'],
338323
'asc',
339324
);
@@ -356,7 +341,7 @@ Slices a pre-sorted result set into a page with an opaque cursor:
356341
/** @var \SimpleSAML\OpenID\Federation $federationTools */
357342

358343
$paginated = $federationTools->entityCollectionPaginator()->paginate(
359-
$sorted, // Pre-sorted array<string, EntityStatement|EntityCollectionEntry>
344+
$sorted, // Pre-sorted array<string, array<string, mixed>|EntityCollectionEntry>
360345
20, // Limit (page size)
361346
null, // Cursor from a previous response's 'next' value, or null
362347
);

src/Federation/EntityCollection.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class EntityCollection
88
{
99
/**
10-
* @param array<string, \SimpleSAML\OpenID\Federation\EntityStatement> $entities Keyed by entity ID
10+
* @param array<string, array<string, mixed>> $entities Keyed by entity ID, value is JWT payload
1111
*/
1212
public function __construct(
1313
protected readonly array $entities,
@@ -16,7 +16,7 @@ public function __construct(
1616

1717

1818
/**
19-
* @return array<string, \SimpleSAML\OpenID\Federation\EntityStatement>
19+
* @return array<string, array<string, mixed>>
2020
*/
2121
public function all(): array
2222
{

src/Federation/EntityCollection/CacheEntityCollectionStore.php

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
class CacheEntityCollectionStore implements EntityCollectionStoreInterface
1313
{
14-
protected const PREFIX = 'federation_entity_ids';
14+
protected const PREFIX = 'federation_entities';
1515

1616

1717
public function __construct(
@@ -22,26 +22,26 @@ public function __construct(
2222
}
2323

2424

25-
public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void
25+
public function store(string $trustAnchorId, array $entities, int $ttl): void
2626
{
2727
try {
2828
$this->cacheDecorator->set(
29-
$this->helpers->json()->encode($entityIds),
29+
$this->helpers->json()->encode($entities),
3030
$ttl,
3131
self::PREFIX,
3232
$trustAnchorId,
3333
);
3434
} catch (Throwable $throwable) {
35-
$this->logger?->error('Unable to store entity IDs in cache.', [
35+
$this->logger?->error('Unable to store entities in cache.', [
3636
'trustAnchorId' => $trustAnchorId,
37-
'entityIds' => $entityIds,
37+
'entities' => $entities,
3838
'exception_message' => $throwable->getMessage(),
3939
]);
4040
}
4141
}
4242

4343

44-
public function getEntityIds(string $trustAnchorId): ?array
44+
public function get(string $trustAnchorId): ?array
4545
{
4646
try {
4747
/** @var ?string $cached */
@@ -52,9 +52,15 @@ public function getEntityIds(string $trustAnchorId): ?array
5252
}
5353

5454
$decoded = $this->helpers->json()->decode($cached);
55-
return $this->helpers->type()->ensureArrayWithValuesAsNonEmptyStrings($decoded);
55+
56+
if (!is_array($decoded)) {
57+
return null;
58+
}
59+
60+
/** @var array<string, array<string, mixed>> $decoded */
61+
return $decoded;
5662
} catch (Throwable $throwable) {
57-
$this->logger?->error('Unable to retrieve entity IDs from cache.', [
63+
$this->logger?->error('Unable to retrieve entities from cache.', [
5864
'trustAnchorId' => $trustAnchorId,
5965
'exception_message' => $throwable->getMessage(),
6066
]);
@@ -63,12 +69,12 @@ public function getEntityIds(string $trustAnchorId): ?array
6369
}
6470

6571

66-
public function clearEntityIds(string $trustAnchorId): void
72+
public function clear(string $trustAnchorId): void
6773
{
6874
try {
6975
$this->cacheDecorator->delete(self::PREFIX, $trustAnchorId);
7076
} catch (Throwable $throwable) {
71-
$this->logger?->error('Unable to clear entity IDs from cache.', [
77+
$this->logger?->error('Unable to clear entities from cache.', [
7278
'trustAnchorId' => $trustAnchorId,
7379
'exception_message' => $throwable->getMessage(),
7480
]);

src/Federation/EntityCollection/EntityCollectionFilter.php

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
88
use SimpleSAML\OpenID\Federation\EntityCollection;
9-
use SimpleSAML\OpenID\Federation\EntityStatement;
109
use SimpleSAML\OpenID\Helpers;
1110

1211
class EntityCollectionFilter
@@ -24,8 +23,8 @@ public function __construct(
2423
* query?: string,
2524
* trust_anchor?: string,
2625
* } $criteria
27-
* @return array<string, \SimpleSAML\OpenID\Federation\EntityStatement> Filtered
28-
* entity configurations keyed by entity ID
26+
* @return array<string, array<string, mixed>> Filtered
27+
* entity payloads keyed by entity ID
2928
*/
3029
public function filter(EntityCollection $entityCollection, array $criteria): array
3130
{
@@ -34,8 +33,12 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
3433
// 1. entity_type
3534
if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) {
3635
$types = $criteria['entity_type'];
37-
$filtered = array_filter($filtered, function (EntityStatement $statement) use ($types): bool {
38-
$metadata = $statement->getMetadata();
36+
$filtered = array_filter($filtered, function (array $payload) use ($types): bool {
37+
$metadata = $payload[ClaimsEnum::Metadata->value] ?? null;
38+
if (!is_array($metadata)) {
39+
return false;
40+
}
41+
3942
foreach ($types as $type) {
4043
if (isset($metadata[$type])) {
4144
return true;
@@ -49,18 +52,14 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
4952
// 2. trust_mark_type
5053
if (isset($criteria['trust_mark_type'])) {
5154
$tmType = $criteria['trust_mark_type'];
52-
$filtered = array_filter($filtered, function (EntityStatement $statement) use ($tmType): bool {
53-
try {
54-
$marks = $statement->getTrustMarks();
55-
if ($marks instanceof \SimpleSAML\OpenID\Federation\Claims\TrustMarksClaimBag) {
56-
foreach ($marks->getAll() as $mark) {
57-
if ($mark->getTrustMarkType() === $tmType) {
58-
return true;
59-
}
55+
$filtered = array_filter($filtered, function (array $payload) use ($tmType): bool {
56+
$marks = $payload[ClaimsEnum::TrustMarks->value] ?? null;
57+
if (is_array($marks)) {
58+
foreach ($marks as $mark) {
59+
if (is_array($mark) && ($mark[ClaimsEnum::TrustMarkType->value] ?? null) === $tmType) {
60+
return true;
6061
}
6162
}
62-
} catch (\Throwable) {
63-
return false;
6463
}
6564

6665
return false;
@@ -70,14 +69,16 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
7069
// 3. query
7170
if (isset($criteria['query']) && $criteria['query'] !== '') {
7271
$q = mb_strtolower($criteria['query']);
73-
$filtered = array_filter($filtered, function (EntityStatement $statement) use ($q): bool {
74-
$sub = mb_strtolower($statement->getSubject());
75-
if (str_contains($sub, $q)) {
72+
$filtered = array_filter($filtered, function (array $payload) use ($q): bool {
73+
$sub = is_string($payload[ClaimsEnum::Sub->value] ?? null) ?
74+
mb_strtolower($payload[ClaimsEnum::Sub->value]) :
75+
'';
76+
if ($sub !== '' && str_contains($sub, $q)) {
7677
return true;
7778
}
7879

79-
$metadata = $statement->getMetadata();
80-
if ($metadata === null) {
80+
$metadata = $payload[ClaimsEnum::Metadata->value] ?? null;
81+
if (!is_array($metadata)) {
8182
return false;
8283
}
8384

@@ -111,11 +112,11 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
111112
// filter on the authority hint if possible.
112113
if (isset($criteria['trust_anchor'])) {
113114
$ta = $criteria['trust_anchor'];
114-
$filtered = array_filter($filtered, function (EntityStatement $statement) use ($ta): bool {
115+
$filtered = array_filter($filtered, function (array $payload) use ($ta): bool {
115116
// In a top-down traversal, everything is subordinate to the TA we started with.
116117
// If the collection contains multiple TAs, we would check authority_hints.
117118
$hints = $this->helpers->arr()->getNestedValue(
118-
$statement->getPayload(),
119+
$payload,
119120
ClaimsEnum::AuthorityHints->value,
120121
);
121122
if (is_array($hints)) {

0 commit comments

Comments
 (0)