From c1de5f1d3feccef72421d9aa5b4b3bee27bd9d38 Mon Sep 17 00:00:00 2001 From: Marko Ivancic Date: Fri, 25 Apr 2025 12:46:39 +0200 Subject: [PATCH] Enable resolving Trust Chain for Trust Anchor only --- .../Factories/TrustChainFactory.php | 13 +++++ src/Federation/TrustChain.php | 32 +++++++--- src/Federation/TrustChainResolver.php | 58 +++++++++++-------- .../Factories/TrustChainFactoryTest.php | 14 +++++ .../src/Federation/TrustChainResolverTest.php | 15 +++++ tests/src/Federation/TrustChainTest.php | 20 ++++++- tests/src/Jws/JwsDecoratorTest.php | 2 +- 7 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/Federation/Factories/TrustChainFactory.php b/src/Federation/Factories/TrustChainFactory.php index 586393f..15e3897 100644 --- a/src/Federation/Factories/TrustChainFactory.php +++ b/src/Federation/Factories/TrustChainFactory.php @@ -77,4 +77,17 @@ public function fromTokens(string ...$tokens): TrustChain return $this->fromStatements(...$statements); } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException + */ + public function forTrustAnchor(EntityStatement $trustAnchorStatement): TrustChain + { + $trustChain = $this->empty(); + + $trustChain->addForTrustAnchorOnly($trustAnchorStatement); + + return $trustChain; + } } diff --git a/src/Federation/TrustChain.php b/src/Federation/TrustChain.php index 8ddee32..b2f0d95 100644 --- a/src/Federation/TrustChain.php +++ b/src/Federation/TrustChain.php @@ -98,14 +98,11 @@ public function getResolvedLeaf(): EntityStatement /** * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException */ - public function getResolvedImmediateSuperior(): EntityStatement + public function getResolvedImmediateSuperior(): ?EntityStatement { $this->validateIsResolved(); - ($immediateSuperior = $this->entities[1] ?? null) || - throw new TrustChainException('Empty immediate superior statement encountered.'); - - return $immediateSuperior; + return $this->entities[1] ?? null; } /** @@ -208,6 +205,27 @@ public function addTrustAnchor(EntityStatement $entityStatement): void $this->isResolved = true; } + /** + * Add a Trust Anchor Entity Configuration to create a single entity statement chain. This accommodates a special + * case for the Trust Chain of the Trust Anchor itself. + * + * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * + * @internal + */ + public function addForTrustAnchorOnly(EntityStatement $entityStatement): void + { + $this->validateIsNotResolved(); + $this->validateIsEmpty(); + $this->validateConfigurationStatement($entityStatement); + + $this->entities[] = $entityStatement; + $this->updateExpirationTime($entityStatement); + + $this->isResolved = true; + } + /** * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -400,7 +418,7 @@ protected function resolveMetadataFor(EntityTypesEnum $entityTypeEnum): void // When an Entity participates in a federation or federations with one or more Entity Types, its Entity // Configuration MUST contain a metadata claim with JSON object values for each of the corresponding // Entity Type Identifiers, even if the values are the empty JSON object {} (when the Entity Type - // has no associated metadata or Immediate Superiors supply any needed metadata). + // has no associated metadata or Immediate Superiors supply any necessary metadata). $leafMetadata = $this->getResolvedLeaf()->getMetadata(); if ( (!is_array($leafMetadata)) || // Claim 'metadata' is optional. @@ -422,7 +440,7 @@ protected function resolveMetadataFor(EntityTypesEnum $entityTypeEnum): void // subject's Entity Configuration. If both metadata and metadata_policy // appear in a Subordinate Statement, then the stated metadata MUST // be applied before the metadata_policy. - $immediateSuperiorMetadata = $this->getResolvedImmediateSuperior()->getMetadata(); + $immediateSuperiorMetadata = $this->getResolvedImmediateSuperior()?->getMetadata(); if ( is_array($immediateSuperiorMetadata) && isset($immediateSuperiorMetadata[$entityTypeEnum->value]) && diff --git a/src/Federation/TrustChainResolver.php b/src/Federation/TrustChainResolver.php index 6656421..4b3ada7 100644 --- a/src/Federation/TrustChainResolver.php +++ b/src/Federation/TrustChainResolver.php @@ -225,32 +225,44 @@ public function for(string $entityId, array $validTrustAnchorIds): TrustChainBag ]; $this->logger?->debug('Start resolving for configuration chain.', $debugConfigChainResolveInfo); try { - // Reverse order so we can start from the Trust Anchor. - $configurationChain = array_reverse($configurationChain); - $currenChainElements = []; - $previousEntity = null; - foreach ($configurationChain as $id => $configurationStatement) { - if (array_key_first($configurationChain) === $id) { - // This is Trust Anchor configuration statement, we need to add it. - array_unshift($currenChainElements, $configurationStatement); - } elseif (!is_null($previousEntity)) { - // We have moved on from the first configuration entity in the chain, so we need to start - // populating subordinate statements. - array_unshift( - $currenChainElements, - $this->entityStatementFetcher->fromCacheOrFetchEndpoint($id, $previousEntity), - ); + // If we only have one element in the configuration chain, check if we are dealing with the + // Trust Chain for Trust Anchor itself. + if ( + (count($configurationChain) === 1) && + (array_key_first($configurationChain) === $entityId) + ) { + // Handle the special Trust Anchor Trust Chain case. + $trustAnchorStatement = current($configurationChain); + $resolvedChains[] = $this->trustChainFactory->forTrustAnchor($trustAnchorStatement); + } else { + // Handle normal Trust Chain resolution. + // Reverse order so we can start from the Trust Anchor. + $configurationChain = array_reverse($configurationChain); + $currenChainElements = []; + $previousEntity = null; + foreach ($configurationChain as $id => $configurationStatement) { + if (array_key_first($configurationChain) === $id) { + // This is Trust Anchor configuration statement, we need to add it. + array_unshift($currenChainElements, $configurationStatement); + } elseif (!is_null($previousEntity)) { + // We have moved on from the first configuration entity in the chain, so we need to + // start populating subordinate statements. + array_unshift( + $currenChainElements, + $this->entityStatementFetcher->fromCacheOrFetchEndpoint($id, $previousEntity), + ); + } + + // We need to add leaf entity configuration statement as the last item in the trust chain. + if (array_key_last($configurationChain) === $id) { + array_unshift($currenChainElements, $configurationStatement); + } + + $previousEntity = $configurationStatement; } - // We need to add leaf entity configuration statement as the last item in the trust chain. - if (array_key_last($configurationChain) === $id) { - array_unshift($currenChainElements, $configurationStatement); - } - - $previousEntity = $configurationStatement; + $resolvedChains[] = $this->trustChainFactory->fromStatements(...$currenChainElements); } - - $resolvedChains[] = $this->trustChainFactory->fromStatements(...$currenChainElements); } catch (Throwable $exception) { $this->logger?->error( sprintf( diff --git a/tests/src/Federation/Factories/TrustChainFactoryTest.php b/tests/src/Federation/Factories/TrustChainFactoryTest.php index e301e80..648bb37 100644 --- a/tests/src/Federation/Factories/TrustChainFactoryTest.php +++ b/tests/src/Federation/Factories/TrustChainFactoryTest.php @@ -137,4 +137,18 @@ public function testCanBuildFromTokens(): void $this->sut()->fromTokens('token', 'token2', 'tokent3'), ); } + + public function testCanBuildForTrustAnchor(): void + { + $expirationTime = time() + 60; + + $trustAnchor = $this->createMock(EntityStatement::class); + $trustAnchor->method('isConfiguration')->willReturn(true); + $trustAnchor->method('getExpirationTime')->willReturn($expirationTime); + + $this->assertInstanceOf( + TrustChain::class, + $this->sut()->forTrustAnchor($trustAnchor), + ); + } } diff --git a/tests/src/Federation/TrustChainResolverTest.php b/tests/src/Federation/TrustChainResolverTest.php index 009dcd8..3870876 100644 --- a/tests/src/Federation/TrustChainResolverTest.php +++ b/tests/src/Federation/TrustChainResolverTest.php @@ -259,6 +259,21 @@ public function testCanResolveMultipleTrustChains(): void $this->sut()->for('l', ['i', 't']); } + public function testCanResolveTrustChainForTrustAnchorOnly(): void + { + $this->entityStatementFetcherMock + ->method('fromCacheOrWellKnownEndpoint') + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); + + $this->trustChainFactoryMock->expects($this->once())->method('forTrustAnchor'); + + $this->trustChainBagFactoryMock->expects($this->once())->method('build'); + $this->cacheDecoratorMock->expects($this->once())->method('set'); + + $this->sut()->for('t', ['t']); + } + public function testTrustChainResolveChecksCacheFirst(): void { $this->cacheDecoratorMock diff --git a/tests/src/Federation/TrustChainTest.php b/tests/src/Federation/TrustChainTest.php index cc540ea..1908527 100644 --- a/tests/src/Federation/TrustChainTest.php +++ b/tests/src/Federation/TrustChainTest.php @@ -105,6 +105,22 @@ public function testCanCreateBasicTrustChain(): void $this->assertNull($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); } + public function testCanCreateTrustChainForTrustAnchorOnly(): void + { + $sut = $this->sut(); + $sut->addForTrustAnchorOnly($this->trustAnchorMock); + + $this->assertFalse($sut->isEmpty()); + $this->assertCount(1, $sut->getEntities()); + $this->assertSame(1, $sut->getResolvedLength()); + $this->assertNotEmpty($sut->jsonSerialize()); + $this->assertSame($this->expirationTime, $sut->getResolvedExpirationTime()); + $this->assertSame($this->trustAnchorMock, $sut->getResolvedLeaf()); + $this->assertSame($this->trustAnchorMock, $sut->getResolvedTrustAnchor()); + $this->assertNotInstanceOf(EntityStatement::class, $sut->getResolvedImmediateSuperior()); + $this->assertNull($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + } + public function testThrowsForNonConfigurationStatementForLeaf(): void { $this->expectException(EntityStatementException::class); @@ -267,7 +283,7 @@ public function testCanGetResolvedMetadataIfNoPoliciesAreDefined(): void $this->assertIsArray($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); } - public function testThrowsOnAttemtpToAddMultipleLeafs(): void + public function testThrowsOnAttemptToAddMultipleLeafs(): void { $this->expectException(TrustChainException::class); $this->expectExceptionMessage('empty'); @@ -286,7 +302,7 @@ public function testThrowsOnAttemtpToAddSubodrinateWithoutLeaf(): void $sut->addSubordinate($this->subordinateMock); } - public function testThrowsOnAttemtpToAddTrustAnchorWithoutSubordinate(): void + public function testThrowsOnAttemptToAddTrustAnchorWithoutSubordinate(): void { $this->expectException(TrustChainException::class); $this->expectExceptionMessage('at least'); diff --git a/tests/src/Jws/JwsDecoratorTest.php b/tests/src/Jws/JwsDecoratorTest.php index 0ec78d6..4532cb3 100644 --- a/tests/src/Jws/JwsDecoratorTest.php +++ b/tests/src/Jws/JwsDecoratorTest.php @@ -12,7 +12,7 @@ #[CoversClass(JwsDecorator::class)] final class JwsDecoratorTest extends TestCase { - protected JWS $jwsMock; + protected \PHPUnit\Framework\MockObject\MockObject $jwsMock; protected function setUp(): void {