Skip to content

Commit cc2087d

Browse files
committed
WIP
1 parent 00e9468 commit cc2087d

9 files changed

Lines changed: 250 additions & 89 deletions

src/Federation.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionStoreInterface;
2929
use SimpleSAML\OpenID\Federation\EntityCollection\InMemoryEntityCollectionStore;
3030
use SimpleSAML\OpenID\Federation\EntityStatementFetcher;
31+
use SimpleSAML\OpenID\Federation\Factories\EntityCollectionFactory;
3132
use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory;
3233
use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory;
3334
use SimpleSAML\OpenID\Federation\Factories\TrustChainBagFactory;
@@ -140,6 +141,8 @@ class Federation
140141

141142
protected ?KeyPairResolver $keyPairResolver = null;
142143

144+
protected ?EntityCollectionFactory $entityCollectionFactory = null;
145+
143146

144147
public function __construct(
145148
protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(),
@@ -377,6 +380,16 @@ public function entityCollectionStore(): EntityCollectionStoreInterface
377380
}
378381

379382

383+
public function entityCollectionFactory(): EntityCollectionFactory
384+
{
385+
return $this->entityCollectionFactory ??= new EntityCollectionFactory(
386+
$this->entityCollectionFilter(),
387+
$this->entityCollectionSorter(),
388+
$this->entityCollectionPaginator(),
389+
);
390+
}
391+
392+
380393
public function federationDiscovery(): FederationDiscovery
381394
{
382395
if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) {
@@ -385,6 +398,7 @@ public function federationDiscovery(): FederationDiscovery
385398
$this->subordinateListingFetcher(),
386399
$this->entityCollectionStore(),
387400
$this->maxCacheDurationDecorator(),
401+
$this->entityCollectionFactory(),
388402
$this->logger,
389403
$this->maxDiscoveryDepth,
390404
);

src/Federation/EntityCollection.php

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,97 @@
44

55
namespace SimpleSAML\OpenID\Federation;
66

7+
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionFilter;
8+
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionPaginator;
9+
use SimpleSAML\OpenID\Federation\EntityCollection\EntityCollectionSorter;
10+
711
class EntityCollection
812
{
913
/**
10-
* @param array<string, array<string, mixed>> $entities Keyed by entity ID, value is JWT payload
14+
* @param array<string, array<string, mixed>> $entities Keyed by entity ID,
15+
* value is JWT payload
1116
*/
1217
public function __construct(
13-
protected readonly array $entities,
18+
protected readonly EntityCollectionFilter $entityCollectionFilter,
19+
protected readonly EntityCollectionSorter $entityCollectionSorter,
20+
protected readonly EntityCollectionPaginator $entityCollectionPaginator,
21+
protected array $entities,
22+
protected ?string $nextPageToken = null,
1423
) {
1524
}
1625

1726

1827
/**
1928
* @return array<string, array<string, mixed>>
2029
*/
21-
public function all(): array
30+
public function getEntities(): array
2231
{
2332
return $this->entities;
2433
}
34+
35+
36+
/**
37+
* Apply filters to the collection. Supported criteria keys:
38+
* - entity_type: array of entity types to include
39+
* (e.g. ['openid_relying_party'])
40+
* - trust_mark_type: array of trust mark types to include
41+
* (e.g. ['https://example.com/marks/approved'])
42+
* - query: string to search for in display_name, organization_name,
43+
* and entity_id (case-insensitive)
44+
*
45+
* @param array{
46+
* entity_type?: string[],
47+
* trust_mark_type?: string[],
48+
* query?: string,
49+
* } $criteria
50+
* @return $this
51+
*/
52+
public function filter(array $criteria): static
53+
{
54+
$this->entities = $this->entityCollectionFilter->filter($this->entities, $criteria);
55+
56+
return $this;
57+
}
58+
59+
60+
/**
61+
* @param non-empty-array<int, non-empty-string[]> $claimPaths
62+
* @param 'asc'|'desc' $sortOrder
63+
* @return $this
64+
*/
65+
public function sortByMetadataClaims(array $claimPaths, string $sortOrder): static
66+
{
67+
$this->entities = $this->entityCollectionSorter->sortByMetadataClaims(
68+
$this->entities,
69+
$claimPaths,
70+
$sortOrder,
71+
);
72+
73+
return $this;
74+
}
75+
76+
77+
/**
78+
* @param positive-int $limit Maximum number of entries to return
79+
* @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER)
80+
*/
81+
public function paginate(int $limit, ?string $from = null): static
82+
{
83+
[
84+
'entities' => $this->entities,
85+
'next' => $this->nextPageToken,
86+
] = $this->entityCollectionPaginator->paginate(
87+
$this->entities,
88+
$limit,
89+
$from,
90+
);
91+
92+
return $this;
93+
}
94+
95+
96+
public function getNextPageToken(): ?string
97+
{
98+
return $this->nextPageToken;
99+
}
25100
}

src/Federation/EntityCollection/EntityCollectionFilter.php

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

77
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
8-
use SimpleSAML\OpenID\Federation\EntityCollection;
98
use SimpleSAML\OpenID\Helpers;
109

1110
class EntityCollectionFilter
@@ -17,23 +16,35 @@ public function __construct(
1716

1817

1918
/**
19+
* Filters a list of entities based on the provided criteria.
20+
*
21+
* The method applies multiple filters in the following order:
22+
* 1. Filters entities by their type, based on the 'entity_type' criteria.
23+
* 2. Filters entities by their trust mark type, based on the
24+
* 'trust_mark_type' criteria.
25+
* 3. Filters entities by a textual query that checks multiple fields
26+
* (e.g., `display_name` or `organization_name`).
27+
*
28+
* @param array<string, array<string, mixed>> $entities The list of entities
29+
* to be filtered. Each entity is expected to be an associative array.
2030
* @param array{
21-
* entity_type?: string[],
22-
* trust_mark_type?: string,
23-
* query?: string,
24-
* trust_anchor?: string,
25-
* } $criteria
26-
* @return array<string, array<string, mixed>> Filtered
27-
* entity payloads keyed by entity ID
31+
* entity_type?: string[],
32+
* trust_mark_type?: string[],
33+
* query?: string,
34+
* } $criteria The array of filtering criteria. It may contain:
35+
* - 'entity_type': An array of entity types to filter by.
36+
* - 'trust_mark_type': An array of trust mark types to filter by.
37+
* - 'query': A string used to perform a case-insensitive search on
38+
* specific fields.
39+
* @return array<string, array<string, mixed>> The filtered list of entities
40+
* that match all provided criteria.
2841
*/
29-
public function filter(EntityCollection $entityCollection, array $criteria): array
42+
public function filter(array $entities, array $criteria): array
3043
{
31-
$filtered = $entityCollection->all();
32-
3344
// 1. entity_type
3445
if (isset($criteria['entity_type']) && $criteria['entity_type'] !== []) {
3546
$types = $criteria['entity_type'];
36-
$filtered = array_filter($filtered, function (array $payload) use ($types): bool {
47+
$entities = array_filter($entities, function (array $payload) use ($types): bool {
3748
$metadata = $payload[ClaimsEnum::Metadata->value] ?? null;
3849
if (!is_array($metadata)) {
3950
return false;
@@ -50,26 +61,35 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
5061
}
5162

5263
// 2. trust_mark_type
53-
if (isset($criteria['trust_mark_type'])) {
54-
$tmType = $criteria['trust_mark_type'];
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;
61-
}
64+
if (isset($criteria['trust_mark_type']) && $criteria['trust_mark_type'] !== []) {
65+
$criteriaTrustMarkTypes = $criteria['trust_mark_type'];
66+
$entities = array_filter($entities, function (array $payload) use ($criteriaTrustMarkTypes): bool {
67+
$entityTrustMarks = $payload[ClaimsEnum::TrustMarks->value] ?? null;
68+
if (!is_array($entityTrustMarks)) {
69+
return false;
70+
}
71+
72+
$entityTrustMarkTypes = [];
73+
foreach ($entityTrustMarks as $mark) {
74+
if (is_array($mark) && isset($mark[ClaimsEnum::TrustMarkType->value])) {
75+
$entityTrustMarkTypes[] = $mark[ClaimsEnum::TrustMarkType->value];
6276
}
6377
}
6478

65-
return false;
79+
foreach ($criteriaTrustMarkTypes as $tmType) {
80+
if (!in_array($tmType, $entityTrustMarkTypes, true)) {
81+
return false;
82+
}
83+
}
84+
85+
return true;
6686
});
6787
}
6888

6989
// 3. query
7090
if (isset($criteria['query']) && $criteria['query'] !== '') {
7191
$q = mb_strtolower($criteria['query']);
72-
$filtered = array_filter($filtered, function (array $payload) use ($q): bool {
92+
$entities = array_filter($entities, function (array $payload) use ($q): bool {
7393
$sub = is_string($payload[ClaimsEnum::Sub->value] ?? null) ?
7494
mb_strtolower($payload[ClaimsEnum::Sub->value]) :
7595
'';
@@ -105,28 +125,6 @@ public function filter(EntityCollection $entityCollection, array $criteria): arr
105125
});
106126
}
107127

108-
// 4. trust_anchor (simple prefix match for now as per spec suggestion,
109-
// or more complex if needed). Historically, in some federation
110-
// implementations, subordination is indicated via id prefix or
111-
// specific claims. For this building block, we'll implement it as a
112-
// filter on the authority hint if possible.
113-
if (isset($criteria['trust_anchor'])) {
114-
$ta = $criteria['trust_anchor'];
115-
$filtered = array_filter($filtered, function (array $payload) use ($ta): bool {
116-
// In a top-down traversal, everything is subordinate to the TA we started with.
117-
// If the collection contains multiple TAs, we would check authority_hints.
118-
$hints = $this->helpers->arr()->getNestedValue(
119-
$payload,
120-
ClaimsEnum::AuthorityHints->value,
121-
);
122-
if (is_array($hints)) {
123-
return in_array($ta, $hints, true);
124-
}
125-
126-
return false;
127-
});
128-
}
129-
130-
return $filtered;
128+
return $entities;
131129
}
132130
}

src/Federation/EntityCollection/EntityCollectionPaginator.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ public function __construct(
1616

1717

1818
/**
19-
* @template T
20-
* @param array<string, T> $entities Full ordered result set (pre-sorted)
21-
* @param positive-int $limit Maximum number of entries to return
22-
* @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER)
23-
* @return array{entities: array<string, T>, next: ?string}
19+
* @param array<string, array<string, mixed>> $entities The list of entities
20+
* to be paginate, ordered (pre-sorted).
21+
* @param positive-int $limit Maximum number of entries to return
22+
* @param string|null $from Opaque cursor (base64 encoded entity ID to start AFTER)
23+
* @return array{entities: array<string, array<string, mixed>>, next: ?string}
2424
*/
2525
public function paginate(array $entities, int $limit, ?string $from = null): array
2626
{

src/Federation/EntityCollection/EntityCollectionResponseFactory.php

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public function __construct(
2929
* trust_mark_type?: string,
3030
* query?: string,
3131
* trust_anchor?: string,
32-
* sort_by?: string,
32+
* sort_by?: string|string[],
3333
* sort_dir?: 'asc'|'desc',
3434
* entity_claims?: string[],
3535
* ui_claims?: string[],
@@ -41,27 +41,44 @@ public function build(string $trustAnchorId, array $requestParams = []): EntityC
4141
{
4242
// 1. Discover full configurations
4343
$entities = $this->federationDiscovery->discover($trustAnchorId);
44-
$collection = new EntityCollection($entities);
44+
$collection = new EntityCollection(
45+
$this->filter,
46+
$this->sorter,
47+
$this->paginator,
48+
$entities->getEntities(),
49+
);
4550

4651
// 2. Filter
47-
$filtered = $this->filter->filter($collection, $requestParams);
52+
$collection->filter($requestParams);
4853

4954
// 3. Sort
5055
if (isset($requestParams['sort_by'])) {
51-
$path = explode('.', $requestParams['sort_by']);
52-
/** @var non-empty-string[] $path */
53-
$filtered = $this->sorter->sortByMetadataClaim(
54-
$filtered,
55-
$path,
56-
(string)($requestParams['sort_dir'] ?? 'asc'),
57-
);
56+
$sortByParams = is_array($requestParams['sort_by'])
57+
? $requestParams['sort_by']
58+
: [$requestParams['sort_by']];
59+
60+
$claimPaths = [];
61+
foreach ($sortByParams as $sortBy) {
62+
if (!is_string($sortBy)) {
63+
continue;
64+
}
65+
$claimPaths[] = explode('.', $sortBy);
66+
}
67+
68+
if ($claimPaths !== []) {
69+
/** @var non-empty-array<int, non-empty-string[]> $claimPaths */
70+
$collection->sortByMetadataClaims(
71+
$claimPaths,
72+
(string)($requestParams['sort_dir'] ?? 'asc'),
73+
);
74+
}
5875
}
5976

6077
// 4. Claims sub-selection (Projection)
6178
$entries = [];
6279
$uiClaims = $requestParams['ui_claims'] ?? null;
6380

64-
foreach ($filtered as $id => $payload) {
81+
foreach ($collection->getEntities() as $id => $payload) {
6582
$metadata = $payload[ClaimsEnum::Metadata->value] ?? [];
6683
if (!is_array($metadata)) {
6784
$metadata = [];

0 commit comments

Comments
 (0)