Skip to content

Commit bc836b5

Browse files
authored
Enable dynamic fetching of own Trust Marks (#300)
* Add initial dynamic Trust Mark fetch capabilities * Add coverage
1 parent 1bab69d commit bc836b5

File tree

9 files changed

+197
-5
lines changed

9 files changed

+197
-5
lines changed

UPGRADE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ and optionally a port (as in all previous module versions).
4747
- federation caching adapter and its arguments
4848
- PKI keys - federation keys used for example to sign federation entity statements
4949
- federation participation limiting based on Trust Marks for RPs
50+
- (from v6.1) own Trust Marks to dynamically fetch
5051
- signer algorithm
5152
- entity statement duration
5253
- organization name

config/module_oidc.php.dist

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,11 +369,20 @@ $config = [
369369
],
370370

371371
// (optional) Federation Trust Mark tokens. An array of tokens (signed JWTs), each representing a Trust Mark
372-
// issued to this entity.
372+
// issued to this entity. This option is primarily intended for long-lasting or non-expiring tokens, so it
373+
// is not necessary to dynamically fetch / refresh them.
373374
ModuleConfig::OPTION_FEDERATION_TRUST_MARK_TOKENS => [
374375
// 'eyJ...GHg',
375376
],
376377

378+
// (optional) Federation Trust Marks for dynamic fetching. An array of key-value pairs, where key is Trust Mark ID
379+
// and value is Trust Mark Issuer ID, each representing a Trust Mark issued to this entity. Each Trust Mark ID
380+
// in this array will be dynamically fetched from noted Trust Mark Issuer as necessary. If federation caching
381+
// is enabled (recommended), fetched Trust Marks will also be cached until their expiry.
382+
ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [
383+
// 'trust-mark-id' => 'trust-mark-issuer-id',
384+
],
385+
377386
// (optional) Federation participation limit by Trust Marks. This is an array with the following format:
378387
// [
379388
// 'trust-anchor-id' => [

src/Controllers/Admin/ConfigController.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function protocolSettings(): Response
6868

6969
public function federationSettings(): Response
7070
{
71-
$trustMarks = null;
71+
$trustMarks = [];
7272
if (is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens())) {
7373
$trustMarks = array_map(
7474
function (string $token): Federation\TrustMark {
@@ -78,6 +78,23 @@ function (string $token): Federation\TrustMark {
7878
);
7979
}
8080

81+
if (is_array($dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks())) {
82+
/**
83+
* @var non-empty-string $trustMarkId
84+
* @var non-empty-string $trustMarkIssuerId
85+
*/
86+
foreach ($dynamicTrustMarks as $trustMarkId => $trustMarkIssuerId) {
87+
$trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher()
88+
->fromCacheOrWellKnownEndpoint($trustMarkIssuerId);
89+
90+
$trustMarks[] = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint(
91+
$trustMarkId,
92+
$this->moduleConfig->getIssuer(),
93+
$trustMarkIssuerConfigurationStatement,
94+
);
95+
}
96+
}
97+
8198
return $this->templateFactory->build(
8299
'oidc:config/federation.twig',
83100
[

src/Controllers/Federation/EntityStatementController.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
1212
use SimpleSAML\Module\oidc\Services\JsonWebKeySetService;
1313
use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService;
14+
use SimpleSAML\Module\oidc\Services\LoggerService;
1415
use SimpleSAML\Module\oidc\Services\OpMetadataService;
1516
use SimpleSAML\Module\oidc\Utils\FederationCache;
1617
use SimpleSAML\Module\oidc\Utils\Routes;
@@ -42,6 +43,7 @@ public function __construct(
4243
private readonly Helpers $helpers,
4344
private readonly Routes $routes,
4445
private readonly Federation $federation,
46+
private readonly LoggerService $loggerService,
4547
private readonly ?FederationCache $federationCache,
4648
) {
4749
if (!$this->moduleConfig->getFederationEnabled()) {
@@ -126,6 +128,8 @@ public function configuration(): Response
126128
$builder = $builder->withClaim(ClaimsEnum::AuthorityHints->value, $authorityHints);
127129
}
128130

131+
$trustMarks = [];
132+
129133
if (
130134
is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens()) &&
131135
(!empty($trustMarkTokens))
@@ -145,7 +149,45 @@ public function configuration(): Response
145149
ClaimsEnum::TrustMark->value => $token,
146150
];
147151
}, $trustMarkTokens);
152+
}
153+
154+
if (
155+
is_array($dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks()) &&
156+
(!empty($dynamicTrustMarks))
157+
) {
158+
/**
159+
* @var non-empty-string $trustMarkId
160+
* @var non-empty-string $trustMarkIssuerId
161+
*/
162+
foreach ($dynamicTrustMarks as $trustMarkId => $trustMarkIssuerId) {
163+
try {
164+
$trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher()
165+
->fromCacheOrWellKnownEndpoint($trustMarkIssuerId);
166+
167+
$trustMarkEntity = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint(
168+
$trustMarkId,
169+
$this->moduleConfig->getIssuer(),
170+
$trustMarkIssuerConfigurationStatement,
171+
);
172+
173+
$trustMarks[] = [
174+
ClaimsEnum::TrustMarkId->value => $trustMarkId,
175+
ClaimsEnum::TrustMark->value => $trustMarkEntity->getToken(),
176+
];
177+
} catch (\Throwable $exception) {
178+
$this->loggerService->error(
179+
'Error fetching Trust Mark: ' . $exception->getMessage(),
180+
[
181+
'trustMarkId' => $trustMarkId,
182+
'subjectId' => $this->moduleConfig->getIssuer(),
183+
'trustMarkIssuerId' => $trustMarkIssuerId,
184+
],
185+
);
186+
}
187+
}
188+
}
148189

190+
if (!empty($trustMarks)) {
149191
$builder = $builder->withClaim(ClaimsEnum::TrustMarks->value, $trustMarks);
150192
}
151193

src/ModuleConfig.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class ModuleConfig
7676
final public const OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED = 'federation_cache_max_duration_for_fetched';
7777
final public const OPTION_FEDERATION_TRUST_ANCHORS = 'federation_trust_anchors';
7878
final public const OPTION_FEDERATION_TRUST_MARK_TOKENS = 'federation_trust_mark_tokens';
79+
final public const OPTION_FEDERATION_DYNAMIC_TRUST_MARKS = 'federation_dynamic_trust_mark_tokens';
7980
final public const OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED = 'federation_cache_duration_for_produced';
8081
final public const OPTION_PROTOCOL_CACHE_ADAPTER = 'protocol_cache_adapter';
8182
final public const OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS = 'protocol_cache_adapter_arguments';
@@ -632,6 +633,16 @@ public function getFederationTrustMarkTokens(): ?array
632633
return empty($trustMarks) ? null : $trustMarks;
633634
}
634635

636+
public function getFederationDynamicTrustMarks(): ?array
637+
{
638+
$dynamicTrustMarks = $this->config()->getOptionalArray(
639+
self::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS,
640+
null,
641+
);
642+
643+
return empty($dynamicTrustMarks) ? null : $dynamicTrustMarks;
644+
}
645+
635646
public function getOrganizationName(): ?string
636647
{
637648
return $this->config()->getOptionalString(

templates/config/federation.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
{% if trustMarks|default is not empty %}
9494
{% for trustMark in trustMarks %}
9595
<p>
96-
- {{ trustMark.getPayload.id }}
96+
- {{ trustMark.getPayload.trust_mark_id }}
9797
<code class="code-box code-box-content">
9898
{{- trustMark.getPayload|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}}
9999
</code>

tests/unit/src/Controllers/Admin/ConfigControllerTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ class ConfigControllerTest extends TestCase
2828
protected MockObject $federationMock;
2929
protected MockObject $routesMock;
3030
protected MockObject $trustMarkFactoryMock;
31+
protected MockObject $entityStatementFetcherMock;
32+
protected MockObject $trustMarkFetcherMock;
3133

3234
protected function setUp(): void
3335
{
@@ -41,6 +43,12 @@ protected function setUp(): void
4143

4244
$this->trustMarkFactoryMock = $this->createMock(TrustMarkFactory::class);
4345
$this->federationMock->method('trustMarkFactory')->willReturn($this->trustMarkFactoryMock);
46+
47+
$this->entityStatementFetcherMock = $this->createMock(Federation\EntityStatementFetcher::class);
48+
$this->federationMock->method('entityStatementFetcher')->willReturn($this->entityStatementFetcherMock);
49+
50+
$this->trustMarkFetcherMock = $this->createMock(Federation\TrustMarkFetcher::class);
51+
$this->federationMock->method('trustMarkFetcher')->willReturn($this->trustMarkFetcherMock);
4452
}
4553

4654
public function sut(
@@ -129,4 +137,25 @@ public function testCanIncludeTrustMarksInFederationSettings(): void
129137

130138
$this->sut()->federationSettings();
131139
}
140+
141+
public function testCanIncludeDynamicTrustMarksInFederationSettings(): void
142+
{
143+
$this->moduleConfigMock->method('getIssuer')->willReturn('issuer-id');
144+
$this->moduleConfigMock->method('getFederationDynamicTrustMarks')
145+
->willReturn(['trust-mark-id' => 'trust-mark-issuer-id']);
146+
147+
$this->entityStatementFetcherMock->expects($this->once())->method('fromCacheOrWellKnownEndpoint')
148+
->with('trust-mark-issuer-id');
149+
150+
$this->trustMarkFetcherMock->expects($this->once())->method('fromCacheOrFederationTrustMarkEndpoint')
151+
->with(
152+
'trust-mark-id',
153+
'issuer-id',
154+
);
155+
156+
$this->templateFactoryMock->expects($this->once())->method('build')
157+
->with('oidc:config/federation.twig');
158+
159+
$this->sut()->federationSettings();
160+
}
132161
}

tests/unit/src/Controllers/Federation/EntityStatementControllerTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
1515
use SimpleSAML\Module\oidc\Services\JsonWebKeySetService;
1616
use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService;
17+
use SimpleSAML\Module\oidc\Services\LoggerService;
1718
use SimpleSAML\Module\oidc\Services\OpMetadataService;
1819
use SimpleSAML\Module\oidc\Utils\FederationCache;
1920
use SimpleSAML\Module\oidc\Utils\Routes;
@@ -30,6 +31,7 @@ class EntityStatementControllerTest extends TestCase
3031
protected MockObject $helpersMock;
3132
protected MockObject $routesMock;
3233
protected MockObject $federationMock;
34+
protected MockObject $loggerServiceMock;
3335
protected MockObject $federationCacheMock;
3436

3537
protected function setUp(): void
@@ -42,6 +44,7 @@ protected function setUp(): void
4244
$this->helpersMock = $this->createMock(Helpers::class);
4345
$this->routesMock = $this->createMock(Routes::class);
4446
$this->federationMock = $this->createMock(Federation::class);
47+
$this->loggerServiceMock = $this->createMock(LoggerService::class);
4548
$this->federationCacheMock = $this->createMock(FederationCache::class);
4649
}
4750

@@ -54,6 +57,7 @@ protected function sut(
5457
?Helpers $helpers = null,
5558
?Routes $routes = null,
5659
?Federation $federation = null,
60+
?LoggerService $loggerService = null,
5761
?FederationCache $federationCache = null,
5862
): EntityStatementController {
5963
$moduleConfig ??= $this->moduleConfigMock;
@@ -64,6 +68,7 @@ protected function sut(
6468
$helpers ??= $this->helpersMock;
6569
$routes ??= $this->routesMock;
6670
$federation ??= $this->federationMock;
71+
$loggerService ??= $this->loggerServiceMock;
6772
$federationCache ??= $this->federationCacheMock;
6873

6974
return new EntityStatementController(
@@ -75,6 +80,7 @@ protected function sut(
7580
$helpers,
7681
$routes,
7782
$federation,
83+
$loggerService,
7884
$federationCache,
7985
);
8086
}

tests/unit/src/ModuleConfigTest.php

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,9 +378,86 @@ public function testCanGetProtocolDiscoveryShowClaimsSupported(): void
378378
$this->assertFalse($this->sut()->getProtocolDiscoveryShowClaimsSupported());
379379
$this->assertTrue(
380380
$this->sut(
381-
null,
382-
[ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => true],
381+
overrides: [ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => true],
383382
)->getProtocolDiscoveryShowClaimsSupported(),
384383
);
385384
}
385+
386+
public function testCanGetProtocolNewCertPath(): void
387+
{
388+
$this->assertNull($this->sut()->getProtocolNewCertPath());
389+
390+
$sut = $this->sut(
391+
overrides: [ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new-cert'],
392+
);
393+
394+
$this->assertStringContainsString('new-cert', $sut->getProtocolNewCertPath());
395+
}
396+
397+
public function testCanGetFederationNewCertPath(): void
398+
{
399+
$this->assertNull($this->sut()->getFederationNewCertPath());
400+
401+
$sut = $this->sut(
402+
overrides: [ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new-cert'],
403+
);
404+
405+
$this->assertStringContainsString('new-cert', $sut->getFederationNewCertPath());
406+
}
407+
408+
public function testCanGetFederationDynamicTrustMarks(): void
409+
{
410+
$this->assertNull($this->sut()->getFederationDynamicTrustMarks());
411+
412+
$sut = $this->sut(
413+
overrides: [
414+
ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [
415+
'trust-mark-id' => 'trust-mark-issuer-id',
416+
],
417+
],
418+
);
419+
420+
$this->assertArrayHasKey(
421+
'trust-mark-id',
422+
$sut->getFederationDynamicTrustMarks(),
423+
);
424+
}
425+
426+
public function testCanGetFederationParticipationLimitByTrustMarks(): void
427+
{
428+
$this->assertArrayHasKey(
429+
'https://ta.example.org/',
430+
$this->sut()->getFederationParticipationLimitByTrustMarks(),
431+
);
432+
}
433+
434+
public function testCanGetTrustMarksNeededForFederationParticipationFor(): void
435+
{
436+
$neededTrustMarks = $this->sut()->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/');
437+
438+
$this->assertArrayHasKey('one_of', $neededTrustMarks);
439+
$this->assertTrue(in_array('trust-mark-id', $neededTrustMarks['one_of']));
440+
}
441+
442+
public function testGetTrustMarksNeededForFederationParticipationForThrowsOnInvalidConfigValue(): void
443+
{
444+
$sut = $this->sut(
445+
overrides: [
446+
ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [
447+
'https://ta.example.org/' => 'invalid',
448+
],
449+
],
450+
);
451+
452+
$this->expectException(ConfigurationError::class);
453+
454+
$sut->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/');
455+
}
456+
457+
public function testCanGetIsFederationParticipationLimitedByTrustMarksFor(): void
458+
{
459+
$this->assertTrue(
460+
$this->sut()->isFederationParticipationLimitedByTrustMarksFor('https://ta.example.org/'),
461+
);
462+
}
386463
}

0 commit comments

Comments
 (0)