Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ and optionally a port (as in all previous module versions).
- federation caching adapter and its arguments
- PKI keys - federation keys used for example to sign federation entity statements
- federation participation limiting based on Trust Marks for RPs
- (from v6.1) own Trust Marks to dynamically fetch
- signer algorithm
- entity statement duration
- organization name
Expand Down
11 changes: 10 additions & 1 deletion config/module_oidc.php.dist
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,20 @@ $config = [
],

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

// (optional) Federation Trust Marks for dynamic fetching. An array of key-value pairs, where key is Trust Mark ID
// and value is Trust Mark Issuer ID, each representing a Trust Mark issued to this entity. Each Trust Mark ID
// in this array will be dynamically fetched from noted Trust Mark Issuer as necessary. If federation caching
// is enabled (recommended), fetched Trust Marks will also be cached until their expiry.
ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [
// 'trust-mark-id' => 'trust-mark-issuer-id',
],

// (optional) Federation participation limit by Trust Marks. This is an array with the following format:
// [
// 'trust-anchor-id' => [
Expand Down
19 changes: 18 additions & 1 deletion src/Controllers/Admin/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function protocolSettings(): Response

public function federationSettings(): Response
{
$trustMarks = null;
$trustMarks = [];
if (is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens())) {
$trustMarks = array_map(
function (string $token): Federation\TrustMark {
Expand All @@ -78,6 +78,23 @@ function (string $token): Federation\TrustMark {
);
}

if (is_array($dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks())) {
/**
* @var non-empty-string $trustMarkId
* @var non-empty-string $trustMarkIssuerId
*/
foreach ($dynamicTrustMarks as $trustMarkId => $trustMarkIssuerId) {
$trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher()
->fromCacheOrWellKnownEndpoint($trustMarkIssuerId);

$trustMarks[] = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint(
$trustMarkId,
$this->moduleConfig->getIssuer(),
$trustMarkIssuerConfigurationStatement,
);
}
}

return $this->templateFactory->build(
'oidc:config/federation.twig',
[
Expand Down
42 changes: 42 additions & 0 deletions src/Controllers/Federation/EntityStatementController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Services\JsonWebKeySetService;
use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Services\OpMetadataService;
use SimpleSAML\Module\oidc\Utils\FederationCache;
use SimpleSAML\Module\oidc\Utils\Routes;
Expand Down Expand Up @@ -42,6 +43,7 @@ public function __construct(
private readonly Helpers $helpers,
private readonly Routes $routes,
private readonly Federation $federation,
private readonly LoggerService $loggerService,
private readonly ?FederationCache $federationCache,
) {
if (!$this->moduleConfig->getFederationEnabled()) {
Expand Down Expand Up @@ -126,6 +128,8 @@ public function configuration(): Response
$builder = $builder->withClaim(ClaimsEnum::AuthorityHints->value, $authorityHints);
}

$trustMarks = [];

if (
is_array($trustMarkTokens = $this->moduleConfig->getFederationTrustMarkTokens()) &&
(!empty($trustMarkTokens))
Expand All @@ -145,7 +149,45 @@ public function configuration(): Response
ClaimsEnum::TrustMark->value => $token,
];
}, $trustMarkTokens);
}

if (
is_array($dynamicTrustMarks = $this->moduleConfig->getFederationDynamicTrustMarks()) &&
(!empty($dynamicTrustMarks))
) {
/**
* @var non-empty-string $trustMarkId
* @var non-empty-string $trustMarkIssuerId
*/
foreach ($dynamicTrustMarks as $trustMarkId => $trustMarkIssuerId) {
try {
$trustMarkIssuerConfigurationStatement = $this->federation->entityStatementFetcher()
->fromCacheOrWellKnownEndpoint($trustMarkIssuerId);

$trustMarkEntity = $this->federation->trustMarkFetcher()->fromCacheOrFederationTrustMarkEndpoint(
$trustMarkId,
$this->moduleConfig->getIssuer(),
$trustMarkIssuerConfigurationStatement,
);

$trustMarks[] = [
ClaimsEnum::TrustMarkId->value => $trustMarkId,
ClaimsEnum::TrustMark->value => $trustMarkEntity->getToken(),
];
} catch (\Throwable $exception) {
$this->loggerService->error(
'Error fetching Trust Mark: ' . $exception->getMessage(),
[
'trustMarkId' => $trustMarkId,
'subjectId' => $this->moduleConfig->getIssuer(),
'trustMarkIssuerId' => $trustMarkIssuerId,
],
);
}
}
}

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

Expand Down
11 changes: 11 additions & 0 deletions src/ModuleConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class ModuleConfig
final public const OPTION_FEDERATION_CACHE_MAX_DURATION_FOR_FETCHED = 'federation_cache_max_duration_for_fetched';
final public const OPTION_FEDERATION_TRUST_ANCHORS = 'federation_trust_anchors';
final public const OPTION_FEDERATION_TRUST_MARK_TOKENS = 'federation_trust_mark_tokens';
final public const OPTION_FEDERATION_DYNAMIC_TRUST_MARKS = 'federation_dynamic_trust_mark_tokens';
final public const OPTION_FEDERATION_CACHE_DURATION_FOR_PRODUCED = 'federation_cache_duration_for_produced';
final public const OPTION_PROTOCOL_CACHE_ADAPTER = 'protocol_cache_adapter';
final public const OPTION_PROTOCOL_CACHE_ADAPTER_ARGUMENTS = 'protocol_cache_adapter_arguments';
Expand Down Expand Up @@ -632,6 +633,16 @@ public function getFederationTrustMarkTokens(): ?array
return empty($trustMarks) ? null : $trustMarks;
}

public function getFederationDynamicTrustMarks(): ?array
{
$dynamicTrustMarks = $this->config()->getOptionalArray(
self::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS,
null,
);

return empty($dynamicTrustMarks) ? null : $dynamicTrustMarks;
}

public function getOrganizationName(): ?string
{
return $this->config()->getOptionalString(
Expand Down
2 changes: 1 addition & 1 deletion templates/config/federation.twig
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
{% if trustMarks|default is not empty %}
{% for trustMark in trustMarks %}
<p>
- {{ trustMark.getPayload.id }}
- {{ trustMark.getPayload.trust_mark_id }}
<code class="code-box code-box-content">
{{- trustMark.getPayload|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_SLASHES')) -}}
</code>
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/src/Controllers/Admin/ConfigControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class ConfigControllerTest extends TestCase
protected MockObject $federationMock;
protected MockObject $routesMock;
protected MockObject $trustMarkFactoryMock;
protected MockObject $entityStatementFetcherMock;
protected MockObject $trustMarkFetcherMock;

protected function setUp(): void
{
Expand All @@ -41,6 +43,12 @@ protected function setUp(): void

$this->trustMarkFactoryMock = $this->createMock(TrustMarkFactory::class);
$this->federationMock->method('trustMarkFactory')->willReturn($this->trustMarkFactoryMock);

$this->entityStatementFetcherMock = $this->createMock(Federation\EntityStatementFetcher::class);
$this->federationMock->method('entityStatementFetcher')->willReturn($this->entityStatementFetcherMock);

$this->trustMarkFetcherMock = $this->createMock(Federation\TrustMarkFetcher::class);
$this->federationMock->method('trustMarkFetcher')->willReturn($this->trustMarkFetcherMock);
}

public function sut(
Expand Down Expand Up @@ -129,4 +137,25 @@ public function testCanIncludeTrustMarksInFederationSettings(): void

$this->sut()->federationSettings();
}

public function testCanIncludeDynamicTrustMarksInFederationSettings(): void
{
$this->moduleConfigMock->method('getIssuer')->willReturn('issuer-id');
$this->moduleConfigMock->method('getFederationDynamicTrustMarks')
->willReturn(['trust-mark-id' => 'trust-mark-issuer-id']);

$this->entityStatementFetcherMock->expects($this->once())->method('fromCacheOrWellKnownEndpoint')
->with('trust-mark-issuer-id');

$this->trustMarkFetcherMock->expects($this->once())->method('fromCacheOrFederationTrustMarkEndpoint')
->with(
'trust-mark-id',
'issuer-id',
);

$this->templateFactoryMock->expects($this->once())->method('build')
->with('oidc:config/federation.twig');

$this->sut()->federationSettings();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
use SimpleSAML\Module\oidc\Services\JsonWebKeySetService;
use SimpleSAML\Module\oidc\Services\JsonWebTokenBuilderService;
use SimpleSAML\Module\oidc\Services\LoggerService;
use SimpleSAML\Module\oidc\Services\OpMetadataService;
use SimpleSAML\Module\oidc\Utils\FederationCache;
use SimpleSAML\Module\oidc\Utils\Routes;
Expand All @@ -30,6 +31,7 @@ class EntityStatementControllerTest extends TestCase
protected MockObject $helpersMock;
protected MockObject $routesMock;
protected MockObject $federationMock;
protected MockObject $loggerServiceMock;
protected MockObject $federationCacheMock;

protected function setUp(): void
Expand All @@ -42,6 +44,7 @@ protected function setUp(): void
$this->helpersMock = $this->createMock(Helpers::class);
$this->routesMock = $this->createMock(Routes::class);
$this->federationMock = $this->createMock(Federation::class);
$this->loggerServiceMock = $this->createMock(LoggerService::class);
$this->federationCacheMock = $this->createMock(FederationCache::class);
}

Expand All @@ -54,6 +57,7 @@ protected function sut(
?Helpers $helpers = null,
?Routes $routes = null,
?Federation $federation = null,
?LoggerService $loggerService = null,
?FederationCache $federationCache = null,
): EntityStatementController {
$moduleConfig ??= $this->moduleConfigMock;
Expand All @@ -64,6 +68,7 @@ protected function sut(
$helpers ??= $this->helpersMock;
$routes ??= $this->routesMock;
$federation ??= $this->federationMock;
$loggerService ??= $this->loggerServiceMock;
$federationCache ??= $this->federationCacheMock;

return new EntityStatementController(
Expand All @@ -75,6 +80,7 @@ protected function sut(
$helpers,
$routes,
$federation,
$loggerService,
$federationCache,
);
}
Expand Down
81 changes: 79 additions & 2 deletions tests/unit/src/ModuleConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,86 @@ public function testCanGetProtocolDiscoveryShowClaimsSupported(): void
$this->assertFalse($this->sut()->getProtocolDiscoveryShowClaimsSupported());
$this->assertTrue(
$this->sut(
null,
[ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => true],
overrides: [ModuleConfig::OPTION_PROTOCOL_DISCOVERY_SHOW_CLAIMS_SUPPORTED => true],
)->getProtocolDiscoveryShowClaimsSupported(),
);
}

public function testCanGetProtocolNewCertPath(): void
{
$this->assertNull($this->sut()->getProtocolNewCertPath());

$sut = $this->sut(
overrides: [ModuleConfig::OPTION_PKI_NEW_CERTIFICATE_FILENAME => 'new-cert'],
);

$this->assertStringContainsString('new-cert', $sut->getProtocolNewCertPath());
}

public function testCanGetFederationNewCertPath(): void
{
$this->assertNull($this->sut()->getFederationNewCertPath());

$sut = $this->sut(
overrides: [ModuleConfig::OPTION_PKI_FEDERATION_NEW_CERTIFICATE_FILENAME => 'new-cert'],
);

$this->assertStringContainsString('new-cert', $sut->getFederationNewCertPath());
}

public function testCanGetFederationDynamicTrustMarks(): void
{
$this->assertNull($this->sut()->getFederationDynamicTrustMarks());

$sut = $this->sut(
overrides: [
ModuleConfig::OPTION_FEDERATION_DYNAMIC_TRUST_MARKS => [
'trust-mark-id' => 'trust-mark-issuer-id',
],
],
);

$this->assertArrayHasKey(
'trust-mark-id',
$sut->getFederationDynamicTrustMarks(),
);
}

public function testCanGetFederationParticipationLimitByTrustMarks(): void
{
$this->assertArrayHasKey(
'https://ta.example.org/',
$this->sut()->getFederationParticipationLimitByTrustMarks(),
);
}

public function testCanGetTrustMarksNeededForFederationParticipationFor(): void
{
$neededTrustMarks = $this->sut()->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/');

$this->assertArrayHasKey('one_of', $neededTrustMarks);
$this->assertTrue(in_array('trust-mark-id', $neededTrustMarks['one_of']));
}

public function testGetTrustMarksNeededForFederationParticipationForThrowsOnInvalidConfigValue(): void
{
$sut = $this->sut(
overrides: [
ModuleConfig::OPTION_FEDERATION_PARTICIPATION_LIMIT_BY_TRUST_MARKS => [
'https://ta.example.org/' => 'invalid',
],
],
);

$this->expectException(ConfigurationError::class);

$sut->getTrustMarksNeededForFederationParticipationFor('https://ta.example.org/');
}

public function testCanGetIsFederationParticipationLimitedByTrustMarksFor(): void
{
$this->assertTrue(
$this->sut()->isFederationParticipationLimitedByTrustMarksFor('https://ta.example.org/'),
);
}
}