Skip to content

Commit ac1a2b7

Browse files
committed
Introduce TrustMarkFetcher
1 parent 235c961 commit ac1a2b7

13 files changed

Lines changed: 488 additions & 12 deletions

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,40 @@ try {
196196
return;
197197
}
198198

199+
```
200+
### Fetching Trust Marks
201+
Federation tools expose Trust Mark Fetcher which you can use to dynamically fetch or refresh (short-living) Trust Marks.
202+
203+
```php
204+
// ...
205+
206+
/** @var \SimpleSAML\OpenID\Federation $federationTools */
207+
208+
// Trust Mark ID that you want to fetch.
209+
$trustMarkId = 'https://example.com/trust-mark/member';
210+
// ID of Subject for which to fetch the Trust Mark.
211+
$subjectId = 'https://leaf-entity.org'
212+
// ID of the Trust Mark Issuer from which to fetch the Trust Mark.
213+
$trustMarkIssuerEntityId = 'https://trust-mark-issuer.org'
214+
215+
try {
216+
// First, fetch the Configuration Statement for Trust Mark Issuer.
217+
$trustMarkIssuerConfigurationStatement = $this->federation
218+
->entityStatementFetcher()
219+
->fromCacheOrWellKnownEndpoint($trustMarkIssuerEntityId);
220+
221+
// Fetch the Trust Mark from Issuer.
222+
$trustMarkEntity = $federationTools->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint(
223+
$trustMarkId,
224+
$subjectId,
225+
$trustMarkIssuerConfigurationStatement
226+
);
227+
228+
} catch (\Throwable $exception) {
229+
$this->logger->error('Trust Mark fetch failed. Error was: ' . $exception->getMessage());
230+
return;
231+
}
232+
199233
```
200234

201235
### Validating Trust Marks

src/Codebooks/ClaimsEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ enum ClaimsEnum: string
3030
// ExpirationTime
3131
case Exp = 'exp';
3232
case FederationFetchEndpoint = 'federation_fetch_endpoint';
33+
case FederationTrustMarkEndpoint = 'federation_trust_mark_endpoint';
3334
case GrantTypes = 'grant_types';
3435
case GrantTypesSupported = 'grant_types_supported';
3536
case HomepageUri = 'homepage_uri';

src/Codebooks/ContentTypesEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ enum ContentTypesEnum: string
88
{
99
case ApplicationJwt = 'application/jwt';
1010
case ApplicationEntityStatementJwt = 'application/entity-statement+jwt';
11+
case ApplicationTrustMarkJwt = 'application/trust-mark+jwt';
1112
}

src/Federation.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator;
2828
use SimpleSAML\OpenID\Federation\MetadataPolicyResolver;
2929
use SimpleSAML\OpenID\Federation\TrustChainResolver;
30+
use SimpleSAML\OpenID\Federation\TrustMarkFetcher;
3031
use SimpleSAML\OpenID\Federation\TrustMarkValidator;
3132
use SimpleSAML\OpenID\Jwks\Factories\JwksFactory;
3233
use SimpleSAML\OpenID\Jws\Factories\JwsParserFactory;
@@ -98,6 +99,8 @@ class Federation
9899

99100
protected ?TrustMarkValidator $trustMarkValidator = null;
100101

102+
protected ?TrustMarkFetcher $trustMarkFetcher = null;
103+
101104
public function __construct(
102105
protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(),
103106
protected readonly SupportedSerializers $supportedSerializers = new SupportedSerializers(),
@@ -243,6 +246,17 @@ public function trustMarkValidator(): TrustMarkValidator
243246
);
244247
}
245248

249+
public function trustMarkFetcher(): TrustMarkFetcher
250+
{
251+
return $this->trustMarkFetcher ??= new TrustMarkFetcher(
252+
$this->trustMarkFactory(),
253+
$this->artifactFetcher(),
254+
$this->maxCacheDurationDecorator,
255+
$this->helpers(),
256+
$this->logger,
257+
);
258+
}
259+
246260
public function helpers(): Helpers
247261
{
248262
return $this->helpers ??= new Helpers();

src/Federation/EntityStatement.php

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -236,24 +236,47 @@ public function getKeyId(): string
236236
/**
237237
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
238238
* @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException
239+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
239240
*
240-
* phpcs:ignore
241+
* @return ?non-empty-string
241242
*/
242243
public function getFederationFetchEndpoint(): ?string
243244
{
244-
// phpcs:disable
245-
// @phpstan-ignore offsetAccess.nonOffsetAccessible (We fall back to null if not available.)
246-
$federationFetchEndpoint = $this->getPayload() // @phpstan-ignore offsetAccess.nonOffsetAccessible (We fall back to null if not available.)
247-
[ClaimsEnum::Metadata->value]
248-
[EntityTypesEnum::FederationEntity->value]
249-
[ClaimsEnum::FederationFetchEndpoint->value] ?? null;
250-
// phpcs:enable
245+
$federationFetchEndpoint = $this->helpers->arr()->getNestedValue(
246+
$this->getPayload(),
247+
ClaimsEnum::Metadata->value,
248+
EntityTypesEnum::FederationEntity->value,
249+
ClaimsEnum::FederationFetchEndpoint->value,
250+
);
251251

252252
if (is_null($federationFetchEndpoint)) {
253253
return null;
254254
}
255255

256-
return $this->helpers->type()->ensureString($federationFetchEndpoint);
256+
return $this->helpers->type()->ensureNonEmptyString($federationFetchEndpoint);
257+
}
258+
259+
/**
260+
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
261+
* @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException
262+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
263+
*
264+
* @return ?non-empty-string
265+
*/
266+
public function getFederationTrustMarkEndpoint(): ?string
267+
{
268+
$federationTrustMarkEndpoint = $this->helpers->arr()->getNestedValue(
269+
$this->getPayload(),
270+
ClaimsEnum::Metadata->value,
271+
EntityTypesEnum::FederationEntity->value,
272+
ClaimsEnum::FederationTrustMarkEndpoint->value,
273+
);
274+
275+
if (is_null($federationTrustMarkEndpoint)) {
276+
return null;
277+
}
278+
279+
return $this->helpers->type()->ensureNonEmptyString($federationTrustMarkEndpoint);
257280
}
258281

259282
/**
@@ -283,6 +306,7 @@ public function verifyWithKeySet(?array $jwks = null, int $signatureIndex = 0):
283306
/**
284307
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
285308
* @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException
309+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
286310
*/
287311
protected function validate(): void
288312
{
@@ -300,6 +324,7 @@ protected function validate(): void
300324
$this->getTrustMarks(...),
301325
$this->getTrustMarkOwners(...),
302326
$this->getFederationFetchEndpoint(...),
327+
$this->getFederationTrustMarkEndpoint(...),
303328
);
304329
}
305330
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Federation;
6+
7+
use Psr\Log\LoggerInterface;
8+
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
9+
use SimpleSAML\OpenID\Codebooks\ContentTypesEnum;
10+
use SimpleSAML\OpenID\Decorators\DateIntervalDecorator;
11+
use SimpleSAML\OpenID\Exceptions\EntityStatementException;
12+
use SimpleSAML\OpenID\Exceptions\FetchException;
13+
use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory;
14+
use SimpleSAML\OpenID\Helpers;
15+
use SimpleSAML\OpenID\Jws\JwsFetcher;
16+
use SimpleSAML\OpenID\Utils\ArtifactFetcher;
17+
18+
class TrustMarkFetcher extends JwsFetcher
19+
{
20+
public function __construct(
21+
private readonly TrustMarkFactory $parsedJwsFactory,
22+
ArtifactFetcher $artifactFetcher,
23+
DateIntervalDecorator $maxCacheDuration,
24+
Helpers $helpers,
25+
?LoggerInterface $logger = null,
26+
) {
27+
parent::__construct($parsedJwsFactory, $artifactFetcher, $maxCacheDuration, $helpers, $logger);
28+
}
29+
30+
protected function buildJwsInstance(string $token): TrustMark
31+
{
32+
return $this->parsedJwsFactory->fromToken($token);
33+
}
34+
35+
public function getExpectedContentTypeHttpHeader(): string
36+
{
37+
return ContentTypesEnum::ApplicationTrustMarkJwt->value;
38+
}
39+
40+
/**
41+
* @param \SimpleSAML\OpenID\Federation\EntityStatement $entityConfiguration Entity from which to use the
42+
* federation_trust_mark_endpoint (issuer).
43+
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
44+
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
45+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
46+
*/
47+
public function fromCacheOrFederationTrustMarkEndpoint(
48+
string $trustMarkId,
49+
string $subjectId,
50+
EntityStatement $entityConfiguration,
51+
): TrustMark {
52+
$trustMarkEndpoint = $entityConfiguration->getFederationTrustMarkEndpoint() ??
53+
throw new EntityStatementException('No federation trust mark endpoint found in entity configuration.');
54+
55+
$this->logger?->debug(
56+
'Trust Mark fetch from cache or federation trust mark endpoint.',
57+
['trustMarkId' => $trustMarkId, 'subjectId' => $subjectId, 'trustMarkEndpoint' => $trustMarkEndpoint],
58+
);
59+
60+
return $this->fromCacheOrNetwork(
61+
$this->helpers->url()->withParams(
62+
$trustMarkEndpoint,
63+
[
64+
ClaimsEnum::TrustMarkId->value => $trustMarkId,
65+
ClaimsEnum::Sub->value => $subjectId,
66+
],
67+
),
68+
);
69+
}
70+
71+
/**
72+
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
73+
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
74+
*/
75+
public function fromCacheOrNetwork(string $uri): TrustMark
76+
{
77+
return $this->fromCache($uri) ?? $this->fromNetwork($uri);
78+
}
79+
80+
/**
81+
* Fetch Trust Mark from cache, if available. URI is used as cache key.
82+
*
83+
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
84+
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
85+
*/
86+
public function fromCache(string $uri): ?TrustMark
87+
{
88+
$trustMark = parent::fromCache($uri);
89+
90+
if (is_null($trustMark)) {
91+
return null;
92+
}
93+
94+
if ($trustMark instanceof TrustMark) {
95+
return $trustMark;
96+
}
97+
98+
// @codeCoverageIgnoreStart
99+
$message = 'Unexpected Trust Mark instance encountered for cache fetch.';
100+
$this->logger?->error(
101+
$message,
102+
['uri' => $uri, 'trustMark' => $trustMark],
103+
);
104+
105+
throw new FetchException($message);
106+
// @codeCoverageIgnoreEnd
107+
}
108+
109+
/**
110+
* Fetch Trust Mark from network. Each successful fetch will be cached, with URI being used as a cache key.
111+
*
112+
* @throws \SimpleSAML\OpenID\Exceptions\FetchException
113+
* @throws \SimpleSAML\OpenID\Exceptions\JwsException
114+
*/
115+
public function fromNetwork(string $uri): TrustMark
116+
{
117+
$trustMark = parent::fromNetwork($uri);
118+
119+
if ($trustMark instanceof TrustMark) {
120+
return $trustMark;
121+
}
122+
123+
// @codeCoverageIgnoreStart
124+
$message = 'Unexpected Trust Mark instance encountered for network fetch.';
125+
$this->logger?->error(
126+
$message,
127+
['uri' => $uri, 'trustMark' => $trustMark],
128+
);
129+
130+
throw new FetchException($message);
131+
// @codeCoverageIgnoreEnd
132+
}
133+
}

src/Helpers/Arr.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,31 @@ public function ensureArrayDepth(array &$array, int|string ...$keys): void
2929

3030
$this->ensureArrayDepth($array[$key], ...$keys);
3131
}
32+
33+
/**
34+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
35+
* @param mixed[] $array
36+
*/
37+
public function getNestedValue(array $array, int|string ...$keys): mixed
38+
{
39+
if (count($keys) > 99) {
40+
throw new OpenIdException('Refusing to recurse to given depth.');
41+
}
42+
43+
if (count($keys) < 1) {
44+
return null;
45+
}
46+
47+
if (count($keys) === 1) {
48+
return $array[array_shift($keys)] ?? null;
49+
}
50+
51+
$key = array_shift($keys);
52+
53+
if (!is_array($nestedArray = $array[$key] ?? null)) {
54+
return null;
55+
}
56+
57+
return $this->getNestedValue($nestedArray, ...$keys);
58+
}
3259
}

src/Jws/JwsFetcher.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public function fromNetwork(string $uri): ParsedJws
8888

8989
$response = $this->artifactFetcher->fromNetwork($uri);
9090

91-
if ($response->getStatusCode() !== 200) {
91+
if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) {
9292
$message = sprintf(
9393
'Unexpected HTTP response for JWS fetch, status code: %s, reason: %s. URI %s',
9494
$response->getStatusCode(),

tests/src/Federation/EntityStatementFetcherTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ public function testFetchFromCacheOrFetchEndpointThrowsIfNoFetchEndpoint(): void
131131
->willReturn(null);
132132

133133
$this->expectException(EntityStatementException::class);
134-
$this->expectExceptionMessage('fetch');
134+
$this->expectExceptionMessage('endpoint');
135135

136136
$this->sut()->fromCacheOrFetchEndpoint('entityId', $this->entityStatementMock);
137137
}

0 commit comments

Comments
 (0)