Skip to content

Commit dcdf2f0

Browse files
committed
WIP
1 parent 42898d9 commit dcdf2f0

22 files changed

Lines changed: 1187 additions & 31 deletions

src/Codebooks/ClaimsEnum.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ enum ClaimsEnum: string
173173

174174
case Expiration_Date = 'expirationDate';
175175

176+
case EntityTypes = 'entity_types';
177+
178+
case FederationCollectionEndpoint = 'federation_collection_endpoint';
179+
176180
case FederationFetchEndpoint = 'federation_fetch_endpoint';
177181

178182
case FederationListEndpoint = 'federation_list_endpoint';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Exceptions;
6+
7+
class EntityDiscoveryException extends OpenIdException
8+
{
9+
}

src/Federation.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919
use SimpleSAML\OpenID\Factories\DateIntervalDecoratorFactory;
2020
use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory;
2121
use SimpleSAML\OpenID\Factories\JwsSerializerManagerDecoratorFactory;
22+
use SimpleSAML\OpenID\Federation\CacheEntityCollectionStore;
23+
use SimpleSAML\OpenID\Federation\EntityCollectionBuilder;
24+
use SimpleSAML\OpenID\Federation\EntityCollectionFetcher;
25+
use SimpleSAML\OpenID\Federation\EntityCollectionFilter;
26+
use SimpleSAML\OpenID\Federation\EntityCollectionPaginator;
27+
use SimpleSAML\OpenID\Federation\EntityCollectionSorter;
28+
use SimpleSAML\OpenID\Federation\EntityCollectionStoreInterface;
2229
use SimpleSAML\OpenID\Federation\EntityStatementFetcher;
2330
use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory;
2431
use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory;
@@ -27,12 +34,16 @@
2734
use SimpleSAML\OpenID\Federation\Factories\TrustMarkDelegationFactory;
2835
use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory;
2936
use SimpleSAML\OpenID\Federation\Factories\TrustMarkStatusResponseFactory;
37+
use SimpleSAML\OpenID\Federation\FederationDiscovery;
38+
use SimpleSAML\OpenID\Federation\InMemoryEntityCollectionStore;
3039
use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator;
3140
use SimpleSAML\OpenID\Federation\MetadataPolicyResolver;
41+
use SimpleSAML\OpenID\Federation\SubordinateListingFetcher;
3242
use SimpleSAML\OpenID\Federation\TrustChainResolver;
3343
use SimpleSAML\OpenID\Federation\TrustMarkFetcher;
3444
use SimpleSAML\OpenID\Federation\TrustMarkStatusResponseFetcher;
3545
use SimpleSAML\OpenID\Federation\TrustMarkValidator;
46+
use SimpleSAML\OpenID\Helpers;
3647
use SimpleSAML\OpenID\Jwks\Factories\JwksDecoratorFactory;
3748
use SimpleSAML\OpenID\Jws\Factories\JwsDecoratorBuilderFactory;
3849
use SimpleSAML\OpenID\Jws\Factories\JwsVerifierDecoratorFactory;
@@ -50,6 +61,8 @@ class Federation
5061

5162
protected int $maxTrustChainDepth;
5263

64+
protected int $maxDiscoveryDepth;
65+
5366
protected ?CacheDecorator $cacheDecorator;
5467

5568
protected HttpClientDecorator $httpClientDecorator;
@@ -60,6 +73,20 @@ class Federation
6073

6174
protected ?JwsVerifierDecorator $jwsVerifierDecorator = null;
6275

76+
protected ?SubordinateListingFetcher $subordinateListingFetcher = null;
77+
78+
protected ?FederationDiscovery $federationDiscovery = null;
79+
80+
protected ?EntityCollectionFetcher $entityCollectionFetcher = null;
81+
82+
protected ?EntityCollectionFilter $entityCollectionFilter = null;
83+
84+
protected ?EntityCollectionSorter $entityCollectionSorter = null;
85+
86+
protected ?EntityCollectionPaginator $entityCollectionPaginator = null;
87+
88+
protected ?EntityCollectionBuilder $entityCollectionBuilder = null;
89+
6390
protected ?EntityStatementFetcher $entityStatementFetcher = null;
6491

6592
protected ?MetadataPolicyResolver $metadataPolicyResolver = null;
@@ -126,11 +153,13 @@ public function __construct(
126153
?Client $client = null,
127154
// phpcs:ignore
128155
protected readonly TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum = TrustMarkStatusEndpointUsagePolicyEnum::NotUsed,
156+
int $maxDiscoveryDepth = 10,
129157
) {
130158
$this->maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration);
131159
$this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory()
132160
->build($timestampValidationLeeway);
133161
$this->maxTrustChainDepth = min(20, max(1, $maxTrustChainDepth));
162+
$this->maxDiscoveryDepth = max(1, $maxDiscoveryDepth);
134163
$this->cacheDecorator = is_null($cache) ? null : $this->cacheDecoratorFactory()->build($cache);
135164
$this->httpClientDecorator = $this->httpClientDecoratorFactory()->build($client);
136165
}
@@ -321,6 +350,80 @@ public function trustMarkFetcher(): TrustMarkFetcher
321350
}
322351

323352

353+
public function subordinateListingFetcher(): SubordinateListingFetcher
354+
{
355+
return $this->subordinateListingFetcher ??= new SubordinateListingFetcher(
356+
$this->artifactFetcher(),
357+
$this->helpers(),
358+
$this->logger,
359+
);
360+
}
361+
362+
363+
public function federationDiscovery(?EntityCollectionStoreInterface $store = null): FederationDiscovery
364+
{
365+
if (!$this->federationDiscovery instanceof \SimpleSAML\OpenID\Federation\FederationDiscovery) {
366+
$effectiveStore = $store ?? ($this->cacheDecorator() instanceof \SimpleSAML\OpenID\Decorators\CacheDecorator
367+
? new CacheEntityCollectionStore($this->cacheDecorator())
368+
: new InMemoryEntityCollectionStore());
369+
370+
$this->federationDiscovery = new FederationDiscovery(
371+
$this->entityStatementFetcher(),
372+
$this->subordinateListingFetcher(),
373+
$effectiveStore,
374+
$this->maxCacheDurationDecorator(),
375+
$this->logger,
376+
$this->maxDiscoveryDepth,
377+
);
378+
}
379+
380+
return $this->federationDiscovery;
381+
}
382+
383+
384+
public function entityCollectionFetcher(): EntityCollectionFetcher
385+
{
386+
return $this->entityCollectionFetcher ??= new EntityCollectionFetcher(
387+
$this->artifactFetcher(),
388+
$this->helpers(),
389+
$this->logger,
390+
);
391+
}
392+
393+
394+
public function entityCollectionFilter(): EntityCollectionFilter
395+
{
396+
return $this->entityCollectionFilter ??= new EntityCollectionFilter($this->helpers());
397+
}
398+
399+
400+
public function entityCollectionSorter(): EntityCollectionSorter
401+
{
402+
return $this->entityCollectionSorter ??= new EntityCollectionSorter($this->helpers());
403+
}
404+
405+
406+
public function entityCollectionPaginator(): EntityCollectionPaginator
407+
{
408+
return $this->entityCollectionPaginator ??= new EntityCollectionPaginator();
409+
}
410+
411+
412+
/**
413+
* @param \SimpleSAML\OpenID\Federation\EntityCollectionStoreInterface|null $store Forwarded to
414+
* federationDiscovery()
415+
*/
416+
public function entityCollectionBuilder(?EntityCollectionStoreInterface $store = null): EntityCollectionBuilder
417+
{
418+
return $this->entityCollectionBuilder ??= new EntityCollectionBuilder(
419+
$this->federationDiscovery($store),
420+
$this->entityCollectionFilter(),
421+
$this->entityCollectionSorter(),
422+
$this->entityCollectionPaginator(),
423+
);
424+
}
425+
426+
324427
public function helpers(): Helpers
325428
{
326429
return $this->helpers ??= new Helpers();
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Federation;
6+
7+
use SimpleSAML\OpenID\Decorators\CacheDecorator;
8+
use Throwable;
9+
10+
class CacheEntityCollectionStore implements EntityCollectionStoreInterface
11+
{
12+
private const PREFIX = 'federation_entity_ids';
13+
14+
15+
public function __construct(
16+
private readonly CacheDecorator $cacheDecorator,
17+
) {
18+
}
19+
20+
21+
public function storeEntityIds(string $trustAnchorId, array $entityIds, int $ttl): void
22+
{
23+
try {
24+
$this->cacheDecorator->set(
25+
json_encode($entityIds, JSON_THROW_ON_ERROR),
26+
$ttl,
27+
self::PREFIX,
28+
$trustAnchorId,
29+
);
30+
} catch (Throwable) {
31+
// Log if needed, or ignore for now as per ArtifactFetcher pattern
32+
}
33+
}
34+
35+
36+
public function getEntityIds(string $trustAnchorId): ?array
37+
{
38+
try {
39+
/** @var ?string $cached */
40+
$cached = $this->cacheDecorator->get(null, self::PREFIX, $trustAnchorId);
41+
42+
if (is_null($cached)) {
43+
return null;
44+
}
45+
46+
/** @var non-empty-string[] $decoded */
47+
$decoded = json_decode($cached, true, 512, JSON_THROW_ON_ERROR);
48+
49+
return $decoded;
50+
} catch (Throwable) {
51+
return null;
52+
}
53+
}
54+
55+
56+
public function clearEntityIds(string $trustAnchorId): void
57+
{
58+
try {
59+
$this->cacheDecorator->delete(self::PREFIX, $trustAnchorId);
60+
} catch (Throwable) {
61+
// Ignore
62+
}
63+
}
64+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Federation;
6+
7+
class EntityCollection
8+
{
9+
/**
10+
* @param array<string, \SimpleSAML\OpenID\Federation\EntityStatement> $entities Keyed by entity ID
11+
*/
12+
public function __construct(
13+
public readonly array $entities,
14+
) {
15+
}
16+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Federation;
6+
7+
class EntityCollectionBuilder
8+
{
9+
public function __construct(
10+
private readonly FederationDiscovery $federationDiscovery,
11+
private readonly EntityCollectionFilter $filter,
12+
private readonly EntityCollectionSorter $sorter,
13+
private readonly EntityCollectionPaginator $paginator,
14+
) {
15+
}
16+
17+
18+
/**
19+
* Build an EntityCollectionResponse.
20+
*
21+
* @param non-empty-string $trustAnchorId
22+
* @param array{
23+
* entity_type?: string[],
24+
* trust_mark_type?: string,
25+
* query?: string,
26+
* trust_anchor?: string,
27+
* sort_by?: string,
28+
* sort_dir?: 'asc'|'desc',
29+
* entity_claims?: string[],
30+
* ui_claims?: string[],
31+
* limit?: positive-int|string,
32+
* from?: string,
33+
* } $requestParams
34+
*/
35+
public function build(string $trustAnchorId, array $requestParams = []): EntityCollectionResponse
36+
{
37+
// 1. Discover and fetch full configurations
38+
$entities = $this->federationDiscovery->discoverAndFetch($trustAnchorId);
39+
$collection = new EntityCollection($entities);
40+
41+
// 2. Filter
42+
$filtered = $this->filter->filter($collection, $requestParams);
43+
44+
// 3. Sort
45+
if (isset($requestParams['sort_by'])) {
46+
$path = explode('.', $requestParams['sort_by']);
47+
/** @var non-empty-string[] $path */
48+
$filtered = $this->sorter->sortByMetadataClaim(
49+
$filtered,
50+
$path,
51+
(string)($requestParams['sort_dir'] ?? 'asc'),
52+
);
53+
}
54+
55+
// 4. Claims sub-selection (Projection)
56+
$entries = [];
57+
$uiClaims = $requestParams['ui_claims'] ?? null;
58+
59+
foreach ($filtered as $id => $statement) {
60+
$metadata = $statement->getMetadata() ?? [];
61+
/** @var non-empty-string[] $entityTypes */
62+
$entityTypes = array_keys($metadata);
63+
64+
// ui_info projection
65+
$uiInfo = null;
66+
if (is_array($uiClaims) && $uiClaims !== []) {
67+
$uiInfo = [];
68+
foreach ($metadata as $payload) {
69+
if (!is_array($payload)) {
70+
continue;
71+
}
72+
73+
foreach ($uiClaims as $claim) {
74+
if (isset($payload[$claim])) {
75+
$uiInfo[$claim] = $payload[$claim];
76+
}
77+
}
78+
}
79+
}
80+
81+
// trust_marks projection is handled by getting them from statement
82+
$trustMarks = null;
83+
try {
84+
// In a real projection, we might filter which trust marks to return,
85+
// but for now we return all if asked or if no specific selection is implemented.
86+
$trustMarks = $statement->getTrustMarks();
87+
} catch (\Throwable) {
88+
}
89+
90+
// If entity_claims is provided, we might want to filter the metadata itself,
91+
// but the EntityCollectionEntry DTO currently separates ui_info.
92+
// For now, project into the Entry VO.
93+
/** @var non-empty-string $id */
94+
$entries[$id] = new EntityCollectionEntry(
95+
$id,
96+
$entityTypes,
97+
$uiInfo,
98+
$trustMarks?->jsonSerialize(),
99+
);
100+
}
101+
102+
// 5. Paginate
103+
$limit = isset($requestParams['limit']) ? (int)$requestParams['limit'] : 100;
104+
$limit = max(1, $limit);
105+
106+
$from = $requestParams['from'] ?? null;
107+
108+
$paginated = $this->paginator->paginate($entries, $limit, $from);
109+
110+
return new EntityCollectionResponse(
111+
array_values($paginated['entities']),
112+
$paginated['next'],
113+
time(), // last_updated
114+
);
115+
}
116+
}

0 commit comments

Comments
 (0)