From 5eb5e45d84473157b203d4ec8d39596307bd6117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Wed, 5 Nov 2025 11:07:58 +0100 Subject: [PATCH 1/3] Start with Trust Mark Status handling --- composer.json | 2 +- src/Codebooks/ClaimsEnum.php | 2 + src/Codebooks/ContentTypesEnum.php | 1 + src/Codebooks/JwtTypesEnum.php | 1 + ...TrustMarkStatusEndpointUsagePolicyEnum.php | 32 ++++ src/Codebooks/TrustMarkStatusEnum.php | 25 +++ src/Decorators/HttpClientDecorator.php | 10 +- src/Exceptions/TrustMarkStatusException.php | 9 + src/Federation.php | 33 ++++ src/Federation/EntityStatement.php | 25 +++ src/Federation/EntityStatementFetcher.php | 15 +- .../Factories/TrustMarkStatusFactory.php | 24 +++ src/Federation/TrustMarkFetcher.php | 15 +- src/Federation/TrustMarkStatus.php | 102 ++++++++++ src/Federation/TrustMarkStatusFetcher.php | 91 +++++++++ src/Federation/TrustMarkValidator.php | 178 ++++++++++++++++++ src/Jws/JwsFetcher.php | 32 ++-- src/Utils/ArtifactFetcher.php | 10 +- .../src/Codebooks/TrustMarkStatusEnumTest.php | 21 +++ .../Factories/TrustMarkStatusFactoryTest.php | 141 ++++++++++++++ .../src/Federation/TrustMarkValidatorTest.php | 9 +- tests/src/FederationTest.php | 2 + 22 files changed, 753 insertions(+), 27 deletions(-) create mode 100644 src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php create mode 100644 src/Codebooks/TrustMarkStatusEnum.php create mode 100644 src/Exceptions/TrustMarkStatusException.php create mode 100644 src/Federation/Factories/TrustMarkStatusFactory.php create mode 100644 src/Federation/TrustMarkStatus.php create mode 100644 src/Federation/TrustMarkStatusFetcher.php create mode 100644 tests/src/Codebooks/TrustMarkStatusEnumTest.php create mode 100644 tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php diff --git a/composer.json b/composer.json index e6f7e24..86d8d7d 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "vendor/bin/phpcbf", "vendor/bin/phpcs -p", "composer update web-token/jwt-framework --with web-token/jwt-framework:^3.0", - "vendor/bin/phpstan", + "vendor/bin/phpstan --memory-limit=1024M", "vendor/bin/phpunit --no-coverage", "composer update web-token/jwt-framework --with web-token/jwt-framework:^4.0", "vendor/bin/phpstan", diff --git a/src/Codebooks/ClaimsEnum.php b/src/Codebooks/ClaimsEnum.php index 93eb8be..8924b1a 100644 --- a/src/Codebooks/ClaimsEnum.php +++ b/src/Codebooks/ClaimsEnum.php @@ -57,6 +57,7 @@ enum ClaimsEnum: string case FederationFetchEndpoint = 'federation_fetch_endpoint'; case FederationListEndpoint = 'federation_list_endpoint'; case FederationTrustMarkEndpoint = 'federation_trust_mark_endpoint'; + case FederationTrustMarkStatusEndpoint = 'federation_trust_mark_status_endpoint'; case Format = 'format'; case GrantTypes = 'grant_types'; case GrantTypesSupported = 'grant_types_supported'; @@ -130,6 +131,7 @@ enum ClaimsEnum: string case ServiceDocumentation = 'service_documentation'; case SignedJwksUri = 'signed_jwks_uri'; case SignedMetadata = 'signed_metadata'; + case Status = 'status'; // Subject case Sub = 'sub'; case SubjectTypesSupported = 'subject_types_supported'; diff --git a/src/Codebooks/ContentTypesEnum.php b/src/Codebooks/ContentTypesEnum.php index fc3bee6..155c33c 100644 --- a/src/Codebooks/ContentTypesEnum.php +++ b/src/Codebooks/ContentTypesEnum.php @@ -9,4 +9,5 @@ enum ContentTypesEnum: string case ApplicationJwt = 'application/jwt'; case ApplicationEntityStatementJwt = 'application/entity-statement+jwt'; case ApplicationTrustMarkJwt = 'application/trust-mark+jwt'; + case ApplicationTrustMarkStatusJwt = 'application/trust-mark-status-response+jwt'; } diff --git a/src/Codebooks/JwtTypesEnum.php b/src/Codebooks/JwtTypesEnum.php index e023046..181687b 100644 --- a/src/Codebooks/JwtTypesEnum.php +++ b/src/Codebooks/JwtTypesEnum.php @@ -10,4 +10,5 @@ enum JwtTypesEnum: string case JwkSetJwt = 'jwk-set+jwt'; case TrustMarkJwt = 'trust-mark+jwt'; case TrustMarkDelegationJwt = 'trust-mark-delegation+jwt'; + case TrustMarkStatusResponseJwt = 'trust-mark-status-response+jwt'; } diff --git a/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php b/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php new file mode 100644 index 0000000..8ca782c --- /dev/null +++ b/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php @@ -0,0 +1,32 @@ + true, + default => false, + }; + } +} diff --git a/src/Decorators/HttpClientDecorator.php b/src/Decorators/HttpClientDecorator.php index 5e0a5b9..5a2be84 100644 --- a/src/Decorators/HttpClientDecorator.php +++ b/src/Decorators/HttpClientDecorator.php @@ -22,12 +22,16 @@ public function __construct(public readonly Client $client = new Client(self::DE /** + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html * @throws \SimpleSAML\OpenID\Exceptions\HttpException */ - public function request(HttpMethodsEnum $httpMethodsEnum, string $uri): ResponseInterface - { + public function request( + HttpMethodsEnum $httpMethodsEnum, + string $uri, + array $options = [], + ): ResponseInterface { try { - $response = $this->client->request($httpMethodsEnum->value, $uri); + $response = $this->client->request($httpMethodsEnum->value, $uri, $options); } catch (Throwable $throwable) { $message = sprintf( 'Error sending HTTP request to %s. Error was: %s', diff --git a/src/Exceptions/TrustMarkStatusException.php b/src/Exceptions/TrustMarkStatusException.php new file mode 100644 index 0000000..e1b40e3 --- /dev/null +++ b/src/Exceptions/TrustMarkStatusException.php @@ -0,0 +1,9 @@ +trustMarkStatusFactory ??= new TrustMarkStatusFactory( + $this->jwsParser(), + $this->jwsVerifierDecorator(), + $this->jwksFactory(), + $this->jwsSerializerManagerDecorator(), + $this->timestampValidationLeewayDecorator, + $this->helpers(), + $this->claimFactory(), + ); + } + + + public function trustMarkStatusFetcher(): TrustMarkStatusFetcher + { + return $this->trustMarkStatusFetcher ??= new TrustMarkStatusFetcher( + $this->trustMarkStatusFactory(), + $this->artifactFetcher(), + $this->maxCacheDurationDecorator, + $this->helpers(), + $this->logger, + ); + } + + public function trustMarkValidator(): TrustMarkValidator { return $this->trustMarkValidator ??= new TrustMarkValidator( $this->trustChainResolver(), $this->trustMarkFactory(), $this->trustMarkDelegationFactory(), + $this->trustMarkStatusFetcher(), $this->maxCacheDurationDecorator, $this->cacheDecorator(), $this->logger, diff --git a/src/Federation/EntityStatement.php b/src/Federation/EntityStatement.php index 89fa317..c6e1055 100644 --- a/src/Federation/EntityStatement.php +++ b/src/Federation/EntityStatement.php @@ -313,6 +313,30 @@ public function getFederationTrustMarkEndpoint(): ?string } + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + * + * @return ?non-empty-string + */ + public function getFederationTrustMarkStatusEndpoint(): ?string + { + $federationTrustMarkEndpoint = $this->helpers->arr()->getNestedValue( + $this->getPayload(), + ClaimsEnum::Metadata->value, + EntityTypesEnum::FederationEntity->value, + ClaimsEnum::FederationTrustMarkStatusEndpoint->value, + ); + + if (is_null($federationTrustMarkEndpoint)) { + return null; + } + + return $this->helpers->type()->ensureNonEmptyString($federationTrustMarkEndpoint); + } + + /** * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -362,6 +386,7 @@ protected function validate(): void $this->getTrustMarkIssuers(...), $this->getFederationFetchEndpoint(...), $this->getFederationTrustMarkEndpoint(...), + $this->getFederationTrustMarkStatusEndpoint(...), ); } } diff --git a/src/Federation/EntityStatementFetcher.php b/src/Federation/EntityStatementFetcher.php index e7cc433..cf80139 100644 --- a/src/Federation/EntityStatementFetcher.php +++ b/src/Federation/EntityStatementFetcher.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\WellKnownEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\EntityStatementException; @@ -133,14 +134,20 @@ public function fromCache(string $uri): ?EntityStatement /** - * Fetch entity statement from network. Each successful fetch will be cached, with URI being used as a cache key. + * Fetch entity statement from network. * + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html + * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function fromNetwork(string $uri): EntityStatement - { - $entityStatement = parent::fromNetwork($uri); + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + ): EntityStatement { + $entityStatement = parent::fromNetwork($uri, $httpMethodsEnum, $options, $shouldCache); if ($entityStatement instanceof \SimpleSAML\OpenID\Federation\EntityStatement) { return $entityStatement; diff --git a/src/Federation/Factories/TrustMarkStatusFactory.php b/src/Federation/Factories/TrustMarkStatusFactory.php new file mode 100644 index 0000000..b90c34f --- /dev/null +++ b/src/Federation/Factories/TrustMarkStatusFactory.php @@ -0,0 +1,24 @@ +jwsParser->parse($token), + $this->jwsVerifierDecorator, + $this->jwksFactory, + $this->jwsSerializerManagerDecorator, + $this->timestampValidationLeeway, + $this->helpers, + $this->claimFactory, + ); + } +} diff --git a/src/Federation/TrustMarkFetcher.php b/src/Federation/TrustMarkFetcher.php index 6bc34d6..56b532d 100644 --- a/src/Federation/TrustMarkFetcher.php +++ b/src/Federation/TrustMarkFetcher.php @@ -7,6 +7,7 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\EntityStatementException; use SimpleSAML\OpenID\Exceptions\FetchException; @@ -113,14 +114,20 @@ public function fromCache(string $uri): ?TrustMark /** - * Fetch Trust Mark from network. Each successful fetch will be cached, with URI being used as a cache key. + * Fetch Trust Mark from network. * + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html + * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function fromNetwork(string $uri): TrustMark - { - $trustMark = parent::fromNetwork($uri); + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + ): TrustMark { + $trustMark = parent::fromNetwork($uri, $httpMethodsEnum, $options, $shouldCache); if ($trustMark instanceof TrustMark) { return $trustMark; diff --git a/src/Federation/TrustMarkStatus.php b/src/Federation/TrustMarkStatus.php new file mode 100644 index 0000000..86261db --- /dev/null +++ b/src/Federation/TrustMarkStatus.php @@ -0,0 +1,102 @@ +getPayloadClaim(ClaimsEnum::TrustMark->value); + + if (is_null($trustMark)) { + throw new TrustMarkStatusException('No Trust Mark claim found.'); + } + + return $this->helpers->type()->ensureNonEmptyString($trustMark); + } + + + /** + * @return non-empty-string + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkStatusException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + */ + public function getStatus(): string + { + $status = $this->getPayloadClaim(ClaimsEnum::Status->value); + + if (is_null($status)) { + throw new TrustMarkStatusException('No Status claim found.'); + } + + return $this->helpers->type()->ensureNonEmptyString($status); + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @return non-empty-string + */ + public function getType(): string + { + $typ = parent::getType() ?? throw new TrustMarkStatusException('No Type header claim found.'); + + if ($typ !== JwtTypesEnum::TrustMarkStatusResponseJwt->value) { + throw new TrustMarkStatusException('Invalid Type header claim.'); + } + + return $typ; + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + */ + protected function validate(): void + { + $this->validateByCallbacks( + $this->getIssuer(...), + $this->getIssuedAt(...), + $this->getTrustMark(...), + $this->getStatus(...), + $this->getType(...), + ); + } +} diff --git a/src/Federation/TrustMarkStatusFetcher.php b/src/Federation/TrustMarkStatusFetcher.php new file mode 100644 index 0000000..9c3f358 --- /dev/null +++ b/src/Federation/TrustMarkStatusFetcher.php @@ -0,0 +1,91 @@ +parsedJwsFactory->fromToken($token); + } + + + public function getExpectedContentTypeHttpHeader(): string + { + return ContentTypesEnum::ApplicationTrustMarkJwt->value; + } + + + /** + * @param \SimpleSAML\OpenID\Federation\TrustMark $trustMark Trust Mark to send it to the + * federation_trust_mark_status_endpoint. + * @param \SimpleSAML\OpenID\Federation\EntityStatement $entityConfiguration Entity from which to use the + * federation_trust_mark_status_endpoint. + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + public function fromFederationTrustMarkStatusEndpoint( + TrustMark $trustMark, + EntityStatement $entityConfiguration, + ): TrustMarkStatus { + $trustMarkStatusEndpoint = $entityConfiguration->getFederationTrustMarkStatusEndpoint() ?? + throw new EntityStatementException('No federation trust mark status endpoint found in entity configuration.'); + + $this->logger?->debug( + 'Trust Mark status fetch from trust mark status endpoint.', + ['trustMarkStatusEndpoint' => $trustMarkStatusEndpoint, 'trustMarkType' => $trustMark->getType()], + ); + + $trustMarkStatus = $this->fromNetwork( + $trustMarkStatusEndpoint, + HttpMethodsEnum::POST, + [ + 'form_params' => [ + 'trust_mark' => $trustMark->getToken(), + ], + ], + false, + ); + + if ($trustMarkStatus instanceof TrustMarkStatus) { + return $trustMarkStatus; + } + + $message = 'Unexpected Trust Mark Status instance encountered for network fetch.'; + $this->logger?->error( + $message, + [ + 'trustMarkStatusEndpoint' => $trustMarkStatusEndpoint, + 'trustMarkType' => $trustMark->getType(), + 'trustMarkStatus' => $trustMarkStatus, + ], + ); + + throw new FetchException($message); + } +} diff --git a/src/Federation/TrustMarkValidator.php b/src/Federation/TrustMarkValidator.php index 23e29da..11da08e 100644 --- a/src/Federation/TrustMarkValidator.php +++ b/src/Federation/TrustMarkValidator.php @@ -6,9 +6,12 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; +use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEnum; use SimpleSAML\OpenID\Decorators\CacheDecorator; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\TrustMarkException; +use SimpleSAML\OpenID\Exceptions\TrustMarkStatusException; use SimpleSAML\OpenID\Federation\Claims\TrustMarksClaimValue; use SimpleSAML\OpenID\Federation\Factories\TrustMarkDelegationFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; @@ -16,13 +19,22 @@ class TrustMarkValidator { + /** + * // phpcs:ignore + * @param \SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum Default + * Trust Mark Status Endpoint Usage Policy to use when none is specified in a particular method. Defaults to + * NotUtilized, meaning that the Trust Mark Status Endpoint will not be used. + */ public function __construct( protected readonly TrustChainResolver $trustChainResolver, protected readonly TrustMarkFactory $trustMarkFactory, protected readonly TrustMarkDelegationFactory $trustMarkDelegationFactory, + protected readonly TrustMarkStatusFetcher $trustMarkStatusFetcher, protected readonly DateIntervalDecorator $maxCacheDurationDecorator, protected readonly ?CacheDecorator $cacheDecorator = null, protected readonly ?LoggerInterface $logger = null, + // phpcs:ignore + protected readonly TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum = TrustMarkStatusEndpointUsagePolicyEnum::NotUtilized, ) { } @@ -99,6 +111,7 @@ public function fromCacheOrDoForTrustMarkType( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { if ( $this->isValidationCachedFor( @@ -110,11 +123,14 @@ public function fromCacheOrDoForTrustMarkType( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMarkType( $trustMarkType, $leafEntityConfiguration, $trustAnchorEntityConfiguration, $expectedJwtType, + $trustMarkStatusEndpointUsagePolicyEnum, ); } @@ -131,6 +147,7 @@ public function doForTrustMarkType( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { $this->logger?->debug( sprintf( @@ -183,6 +200,8 @@ public function doForTrustMarkType( ), ); + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + foreach ($trustMarksClaimValues as $idx => $trustMarksClaimValue) { $this->logger?->debug( sprintf( @@ -201,6 +220,7 @@ public function doForTrustMarkType( $leafEntityConfiguration, $trustAnchorEntityConfiguration, $expectedJwtType, + $trustMarkStatusEndpointUsagePolicyEnum, ); $this->logger?->debug( @@ -254,6 +274,7 @@ public function fromCacheOrDoForTrustMarksClaimValue( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { if ( $this->isValidationCachedFor( @@ -265,11 +286,14 @@ public function fromCacheOrDoForTrustMarksClaimValue( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMarksClaimValue( $trustMarksClaimValue, $leafEntityConfiguration, $trustAnchorEntityConfiguration, $expectedJwtType, + $trustMarkStatusEndpointUsagePolicyEnum, ); } @@ -288,13 +312,17 @@ public function doForTrustMarksClaimValue( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { $trustMark = $this->validateTrustMarksClaimValue($trustMarksClaimValue, $expectedJwtType); + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMark( $trustMark, $leafEntityConfiguration, $trustAnchorEntityConfiguration, + $trustMarkStatusEndpointUsagePolicyEnum, ); } @@ -378,6 +406,7 @@ public function fromCacheOrDoForTrustMark( TrustMark $trustMark, EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { if ( $this->isValidationCachedFor( @@ -389,10 +418,13 @@ public function fromCacheOrDoForTrustMark( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMark( $trustMark, $leafEntityConfiguration, $trustAnchorEntityConfiguration, + $trustMarkStatusEndpointUsagePolicyEnum, ); } @@ -405,18 +437,23 @@ public function fromCacheOrDoForTrustMark( * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException * @throws \SimpleSAML\OpenID\Exceptions\JwksException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ public function doForTrustMark( TrustMark $trustMark, EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, ): void { + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->logger?->debug( 'Validating Trust Mark.', [ 'trustMarkPayload' => $trustMark->getPayload(), 'leafEntityConfigurationPayload' => $leafEntityConfiguration->getPayload(), 'trustAnchorEntityConfigurationPayload' => $trustAnchorEntityConfiguration->getPayload(), + 'trustMarkStatusEndpointUsagePolicyEnum' => $trustMarkStatusEndpointUsagePolicyEnum->name, ], ); @@ -433,6 +470,19 @@ public function doForTrustMark( $trustAnchorEntityConfiguration, )->getResolvedLeaf(); + if ( + $this->shouldUseTrustMarkStatusEndpoint( + $trustMark, + $trustMarkIssuerEntityConfiguration, + $trustMarkStatusEndpointUsagePolicyEnum, + ) + ) { + $this->validateUsingTrustMarkStatusEndpoint( + $trustMark, + $trustMarkIssuerEntityConfiguration, + ); + } + $this->validateTrustMarkSignature($trustMark, $trustMarkIssuerEntityConfiguration); $this->validateTrustMarkDelegation($trustMark, $trustAnchorEntityConfiguration); @@ -816,4 +866,132 @@ public function validateTrustMarkDelegation( $this->logger?->debug('Trust Mark delegation validated.'); } + + + public function getDefaultTrustMarkStatusEndpointUsagePolicyEnum(): TrustMarkStatusEndpointUsagePolicyEnum + { + return $this->defaultTrustMarkStatusEndpointUsagePolicyEnum; + } + + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + public function shouldUseTrustMarkStatusEndpoint( + TrustMark $trustMark, + EntityStatement $trustMarkIssuerEntityConfiguration, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + ): bool { + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + + if ($trustMarkStatusEndpointUsagePolicyEnum === TrustMarkStatusEndpointUsagePolicyEnum::Required) { + return true; + } + + if ( + $trustMarkStatusEndpointUsagePolicyEnum === + TrustMarkStatusEndpointUsagePolicyEnum::RequiredForNonExpiringTrustMarksOnly + ) { + return $trustMark->getExpirationTime() === null; + } + + if ( + $trustMarkStatusEndpointUsagePolicyEnum === + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvided + ) { + return $trustMarkIssuerEntityConfiguration->getFederationTrustMarkStatusEndpoint() !== null; + } + + if ( + $trustMarkStatusEndpointUsagePolicyEnum === + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly + ) { + return $trustMark->getExpirationTime() === null && + $trustMarkIssuerEntityConfiguration->getFederationTrustMarkStatusEndpoint() !== null; + } + + return false; + } + + + /** + * @param non-empty-string[] $additionallyValidStatues Array of additional statuses that are considered valid + * in addition to those defined by the OpenID Federation specification. + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException + * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException + * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkStatusException + */ + public function validateUsingTrustMarkStatusEndpoint( + TrustMark $trustMark, + EntityStatement $trustMarkIssuerEntityConfiguration, + array $additionallyValidStatues = [], + ): void { + $this->logger?->debug('Validating Trust Mark using Trust Mark Status Endpoint.'); + + try { + $trustMarkStatus = $this->trustMarkStatusFetcher->fromFederationTrustMarkStatusEndpoint( + $trustMark, + $trustMarkIssuerEntityConfiguration, + ); + } catch (Throwable $throwable) { + $message = 'Error fetching Trust Mark Status from Trust Mark Status Endpoint'; + $this->logger?->error($message, [ + 'error' => $throwable->getMessage(), + ]); + + throw new TrustMarkException($message); + } + + $this->logger?->debug( + 'Successfully fetched Trust Mark Status from Trust Mark Status Endpoint.', + ['trustMarkStatus' => $trustMarkStatus->getStatus()], + ); + + $trustMarkStatusEnum = TrustMarkStatusEnum::tryFrom($trustMarkStatus->getStatus()); + + if ($trustMarkStatusEnum instanceof TrustMarkStatusEnum) { + $this->logger?->debug( + 'Trust Mark Status is one of the specified statuses, checking validity.', + ['trustMarkStatusEnum' => $trustMarkStatusEnum], + ); + + if ($trustMarkStatusEnum->isValid()) { + $this->logger?->debug('Trust Mark Status is valid.'); + return; + } + + throw new TrustMarkStatusException( + sprintf('Trust Mark Status is not valid. Reason: %s', $trustMarkStatusEnum->value), + ); + } + + if ($additionallyValidStatues === []) { + throw new TrustMarkStatusException( + sprintf('Trust Mark Status %s is not valid.', $trustMarkStatus->getStatus()), + ); + } + + $this->logger?->debug( + 'Trust Mark Status is not one of the specified statuses, checking validity based on user-defined statuses.', + ['additionallyValidStatues' => $additionallyValidStatues], + ); + + if (in_array($trustMarkStatus->getStatus(), $additionallyValidStatues, true)) { + $this->logger?->debug( + 'Trust Mark Status is valid based on user-defined statuses.', + [ + 'trustMarkStatus' => $trustMarkStatus->getStatus(), + 'additionallyValidStatues' => $additionallyValidStatues, + ], + ); + return; + } + + throw new TrustMarkStatusException( + sprintf('Trust Mark Status %s is not valid.', $trustMarkStatus->getStatus()), + ); + } } diff --git a/src/Jws/JwsFetcher.php b/src/Jws/JwsFetcher.php index a3c2893..5f1692b 100644 --- a/src/Jws/JwsFetcher.php +++ b/src/Jws/JwsFetcher.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum; +use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\FetchException; use SimpleSAML\OpenID\Helpers; @@ -79,19 +80,25 @@ public function fromCache(string $uri): ?ParsedJws /** - * Fetch JWS from network. Each successful fetch will be cached, with URI being used as a cache key. + * Fetch JWS from the network. * + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html + * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ - public function fromNetwork(string $uri): ParsedJws - { + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + ): ParsedJws { $this->logger?->debug( 'Trying to fetch JWS token from network.', ['uri' => $uri], ); - $response = $this->artifactFetcher->fromNetwork($uri); + $response = $this->artifactFetcher->fromNetwork($uri, $httpMethodsEnum, $options); if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) { $message = sprintf( @@ -126,15 +133,18 @@ public function fromNetwork(string $uri): ParsedJws $this->logger?->debug('Proceeding to JWS instance building.'); $jwsInstance = $this->buildJwsInstance($token); - $this->logger?->debug('JWS instance built, saving its token to cache.', ['uri' => $uri, 'token' => $token]); + $this->logger?->debug('JWS instance built.', ['uri' => $uri, 'token' => $token]); - $cacheTtl = is_int($expirationTime = $jwsInstance->getExpirationTime()) ? - $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime( - $expirationTime, - ) : - $this->maxCacheDuration->getInSeconds(); + if ($shouldCache) { + $this->logger?->debug('Saving JWS token to cache.', ['uri' => $uri, 'token' => $token]); + $cacheTtl = is_int($expirationTime = $jwsInstance->getExpirationTime()) ? + $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime( + $expirationTime, + ) : + $this->maxCacheDuration->getInSeconds(); - $this->artifactFetcher->cacheIt($token, $cacheTtl, $uri); + $this->artifactFetcher->cacheIt($token, $cacheTtl, $uri); + } $this->logger?->debug('Returning built JWS instance.', ['uri' => $uri, 'token' => $token]); diff --git a/src/Utils/ArtifactFetcher.php b/src/Utils/ArtifactFetcher.php index 8fd49a7..77b45ca 100644 --- a/src/Utils/ArtifactFetcher.php +++ b/src/Utils/ArtifactFetcher.php @@ -69,13 +69,17 @@ public function fromCacheAsString(string $keyElement, string ...$keyElements): ? /** + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html * @throws \SimpleSAML\OpenID\Exceptions\FetchException */ - public function fromNetwork(string $uri): ResponseInterface - { + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + ): ResponseInterface { $this->logger?->debug('Fetching artifact on network from URI.', ['uri' => $uri]); try { - $response = $this->httpClientDecorator->request(HttpMethodsEnum::GET, $uri); + $response = $this->httpClientDecorator->request($httpMethodsEnum, $uri, $options); } catch (Throwable $throwable) { $message = sprintf( 'Error sending HTTP request to %s. Error was: %s', diff --git a/tests/src/Codebooks/TrustMarkStatusEnumTest.php b/tests/src/Codebooks/TrustMarkStatusEnumTest.php new file mode 100644 index 0000000..09e6040 --- /dev/null +++ b/tests/src/Codebooks/TrustMarkStatusEnumTest.php @@ -0,0 +1,21 @@ +assertTrue(TrustMarkStatusEnum::Active->isValid()); + $this->assertFalse(TrustMarkStatusEnum::Expired->isValid());; + $this->assertFalse(TrustMarkStatusEnum::Revoked->isValid());; + $this->assertFalse(TrustMarkStatusEnum::Invalid->isValid()); + } +} diff --git a/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php b/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php new file mode 100644 index 0000000..5950fb6 --- /dev/null +++ b/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php @@ -0,0 +1,141 @@ + 'RS256', + 'typ' => 'trust-mark-status-response+jwt', + 'kid' => 'fsQ45F0D916RdKEeTjta8DYWiodjthouHrVWgOXBrkk', + ]; + + protected array $samplePayload = [ + 'iss' => 'https://www.example.com/trust-mark-issuer', + 'trust_mark' => 'trust-mark-token', + 'iat' => 1759897995, + 'status' => 'active', + ]; + + + protected function setUp(): void + { + $this->signatureMock = $this->createMock(Signature::class); + + $jwsMock = $this->createMock(JWS::class); + $jwsMock->method('getPayload') + ->willReturn('json-payload-string'); // Just so we have non-empty value. + $jwsMock->method('getSignature')->willReturn($this->signatureMock); + + $jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $jwsDecoratorMock->method('jws')->willReturn($jwsMock); + + $this->jwsParserMock = $this->createMock(JwsParser::class); + $this->jwsParserMock->method('parse')->willReturn($jwsDecoratorMock); + + $this->jwsVerifierDecoratorMock = $this->createMock(JwsVerifierDecorator::class); + $this->jwksFactoryMock = $this->createMock(JwksFactory::class); + $this->jwsSerializerManagerMock = $this->createMock(JwsSerializerManagerDecorator::class); + $this->dateIntervalDecoratorMock = $this->createMock(DateIntervalDecorator::class); + + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $typeHelperMock = $this->createMock(Helpers\Type::class); + $this->helpersMock->method('type')->willReturn($typeHelperMock); + + $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); + $typeHelperMock->method('ensureInt')->willReturnArgument(0); + + $this->claimFactoryMock = $this->createMock(ClaimFactory::class); + } + + + protected function sut( + ?JwsParser $jwsParser = null, + ?JwsVerifierDecorator $jwsVerifierDecorator = null, + ?JwksFactory $jwksFactory = null, + ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, + ?DateIntervalDecorator $dateIntervalDecorator = null, + ?Helpers $helpers = null, + ?ClaimFactory $claimFactory = null, + ): TrustMarkStatusFactory { + $jwsParser ??= $this->jwsParserMock; + $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; + $jwksFactory ??= $this->jwksFactoryMock; + $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerMock; + $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; + $helpers ??= $this->helpersMock; + $claimFactory ??= $this->claimFactoryMock; + + return new TrustMarkStatusFactory( + $jwsParser, + $jwsVerifierDecorator, + $jwksFactory, + $jwsSerializerManagerDecorator, + $dateIntervalDecorator, + $helpers, + $claimFactory, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(TrustMarkStatusFactory::class, $this->sut()); + } + + public function testCanBuildFromToken(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->samplePayload); + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + + $this->assertInstanceOf( + TrustMarkStatus::class, + $this->sut()->fromToken('token'), + ); + } +} diff --git a/tests/src/Federation/TrustMarkValidatorTest.php b/tests/src/Federation/TrustMarkValidatorTest.php index 81184f6..fbaa427 100644 --- a/tests/src/Federation/TrustMarkValidatorTest.php +++ b/tests/src/Federation/TrustMarkValidatorTest.php @@ -23,6 +23,7 @@ use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Federation\TrustMark; use SimpleSAML\OpenID\Federation\TrustMarkDelegation; +use SimpleSAML\OpenID\Federation\TrustMarkStatusFetcher; use SimpleSAML\OpenID\Federation\TrustMarkValidator; #[CoversClass(TrustMarkValidator::class)] @@ -34,6 +35,8 @@ final class TrustMarkValidatorTest extends TestCase protected MockObject $trustMarkDelegationFactoryMock; + protected MockObject $trustMarkStatusFetcherMock; + protected MockObject $maxCacheDurationDecoratorMock; protected MockObject $cacheDecoratorMock; @@ -66,6 +69,7 @@ protected function setUp(): void $this->trustChainResolverMock = $this->createMock(TrustChainResolver::class); $this->trustMarkFactoryMock = $this->createMock(TrustMarkFactory::class); $this->trustMarkDelegationFactoryMock = $this->createMock(TrustMarkDelegationFactory::class); + $this->trustMarkStatusFetcherMock = $this->createMock(TrustMarkStatusFetcher::class); $this->maxCacheDurationDecoratorMock = $this->createMock(DateIntervalDecorator::class); $this->cacheDecoratorMock = $this->createMock(CacheDecorator::class); $this->loggerMock = $this->createMock(LoggerInterface::class); @@ -94,6 +98,7 @@ protected function sut( ?TrustChainResolver $trustChainResolver = null, ?TrustMarkFactory $trustMarkFactory = null, ?TrustMarkDelegationFactory $trustMarkDelegationFactory = null, + ?TrustMarkStatusFetcher $trustMarkStatusFetcher = null, ?DateIntervalDecorator $maxCacheDurationDecorator = null, ?CacheDecorator $cacheDecorator = null, ?LoggerInterface $logger = null, @@ -101,6 +106,7 @@ protected function sut( $trustChainResolver ??= $this->trustChainResolverMock; $trustMarkFactory ??= $this->trustMarkFactoryMock; $trustMarkDelegationFactory ??= $this->trustMarkDelegationFactoryMock; + $trustMarkStatusFetcher ??= $this->trustMarkStatusFetcherMock; $maxCacheDurationDecorator ??= $this->maxCacheDurationDecoratorMock; $cacheDecorator ??= $this->cacheDecoratorMock; $logger ??= $this->loggerMock; @@ -109,6 +115,7 @@ protected function sut( $trustChainResolver, $trustMarkFactory, $trustMarkDelegationFactory, + $trustMarkStatusFetcher, $maxCacheDurationDecorator, $cacheDecorator, $logger, @@ -170,6 +177,7 @@ public function testIsValidationCachedForReturnsFalseIfNoCacheInstance(): void $this->trustChainResolverMock, $this->trustMarkFactoryMock, $this->trustMarkDelegationFactoryMock, + $this->trustMarkStatusFetcherMock, $this->maxCacheDurationDecoratorMock, ); @@ -795,7 +803,6 @@ public function testValidateTrustMarkIssuersThrowsForIssuerNotAdvertisedByTrustA $this->expectException(TrustMarkException::class); $this->expectExceptionMessage('not issued by any'); - ; $this->sut()->validateTrustMarkIssuers( $this->trustMarkMock, diff --git a/tests/src/FederationTest.php b/tests/src/FederationTest.php index 12057bb..7c2988c 100644 --- a/tests/src/FederationTest.php +++ b/tests/src/FederationTest.php @@ -33,6 +33,7 @@ use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Federation\TrustMarkFetcher; +use SimpleSAML\OpenID\Federation\TrustMarkStatusFetcher; use SimpleSAML\OpenID\Federation\TrustMarkValidator; use SimpleSAML\OpenID\Jws\AbstractJwsFetcher; use SimpleSAML\OpenID\Jws\Factories\JwsParserFactory; @@ -77,6 +78,7 @@ #[UsesClass(TrustMarkDelegationFactory::class)] #[UsesClass(TrustMarkValidator::class)] #[UsesClass(TrustMarkFetcher::class)] +#[UsesClass(TrustMarkStatusFetcher::class)] final class FederationTest extends TestCase { protected MockObject $supportedAlgorithmsMock; From 36a3b95fb84efcb2260436e75b20913d5316ea75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Fri, 7 Nov 2025 17:34:40 +0100 Subject: [PATCH 2/3] WIP tests --- README.md | 13 +- src/Codebooks/ContentTypesEnum.php | 2 +- ...TrustMarkStatusEndpointUsagePolicyEnum.php | 2 +- src/Federation/EntityStatementFetcher.php | 2 + src/Federation/TrustMarkFetcher.php | 2 + src/Federation/TrustMarkStatusFetcher.php | 36 ++- src/Federation/TrustMarkValidator.php | 28 +- src/Jws/JwsFetcher.php | 4 +- .../src/Codebooks/TrustMarkStatusEnumTest.php | 44 +-- tests/src/Federation/EntityStatementTest.php | 20 ++ .../Factories/TrustMarkStatusFactoryTest.php | 283 +++++++++--------- .../Federation/TrustMarkStatusFetcherTest.php | 139 +++++++++ tests/src/Federation/TrustMarkStatusTest.php | 172 +++++++++++ .../src/Federation/TrustMarkValidatorTest.php | 120 ++++++++ 14 files changed, 685 insertions(+), 182 deletions(-) create mode 100644 tests/src/Federation/TrustMarkStatusFetcherTest.php create mode 100644 tests/src/Federation/TrustMarkStatusTest.php diff --git a/README.md b/README.md index b8f8880..8265ede 100644 --- a/README.md +++ b/README.md @@ -236,10 +236,11 @@ try { ### Validating Trust Marks -Federation tools expose Trust Mark Validator with several methods for validating Trust Marks, with the most common -one being the one to validate Trust Mark for some entity simply based on the Trust Mark Type. +Federation tools expose Trust Mark Validator with several methods for validating +Trust Marks, with the most common one being the one to validate Trust Mark for +some entity simply based on the Trust Mark Type. -If cache is utilized, Trust Mark validation will be cached with cache TTL being the minimum expiration +If cache is used, Trust Mark validation will be cached with cache TTL being the minimum expiration time of Trust Mark, Leaf Entity Statement or `maxCacheDuration`, whatever is smaller. ```php @@ -257,7 +258,7 @@ $leafEntityConfigurationStatement = $trustChain->getResolvedLeaf(); $trustAnchorConfigurationStatement = $trustChain->getResolvedTrustAnchor(); try { - // Example which queries cache for previously validated Trust Mark, and does formal validation if not cached. + // Example which queries cache for previously validated Trust Mark and does formal validation if not cached. $federationTools->trustMarkValidator()->fromCacheOrDoForTrustMarkType( $trustMarkType, $leafEntityConfigurationStatement, @@ -265,12 +266,14 @@ try { $expectedJwtType = \SimpleSAML\OpenID\Codebooks\JwtTypesEnum::TrustMarkJwt, ); - // Example which always does formal validation (does not use cache). + // Example which always does formal validation (does not use cache), and requires usage of Trust Mark + // Status Endpoint for non-expiring Trust Marks. $federationTools->trustMarkValidator()->doForTrustMarkType( $trustMarkType, $leafEntityConfigurationStatement, $trustAnchorConfigurationStatement, $expectedJwtType = \SimpleSAML\OpenID\Codebooks\JwtTypesEnum::TrustMarkJwt, + \SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum::RequiredForNonExpiringTrustMarksOnly, ); } catch (\Throwable $exception) { $this->logger->error('Trust Mark validation failed. Error was: ' . $exception->getMessage()); diff --git a/src/Codebooks/ContentTypesEnum.php b/src/Codebooks/ContentTypesEnum.php index 155c33c..06e27bf 100644 --- a/src/Codebooks/ContentTypesEnum.php +++ b/src/Codebooks/ContentTypesEnum.php @@ -9,5 +9,5 @@ enum ContentTypesEnum: string case ApplicationJwt = 'application/jwt'; case ApplicationEntityStatementJwt = 'application/entity-statement+jwt'; case ApplicationTrustMarkJwt = 'application/trust-mark+jwt'; - case ApplicationTrustMarkStatusJwt = 'application/trust-mark-status-response+jwt'; + case ApplicationTrustMarkStatusResponseJwt = 'application/trust-mark-status-response+jwt'; } diff --git a/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php b/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php index 8ca782c..b6063b7 100644 --- a/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php +++ b/src/Codebooks/TrustMarkStatusEndpointUsagePolicyEnum.php @@ -28,5 +28,5 @@ enum TrustMarkStatusEndpointUsagePolicyEnum case RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly; // Trust Mark will not be checked using the Trust Mark Status Endpoint. - case NotUtilized; + case NotUsed; } diff --git a/src/Federation/EntityStatementFetcher.php b/src/Federation/EntityStatementFetcher.php index cf80139..5591a2b 100644 --- a/src/Federation/EntityStatementFetcher.php +++ b/src/Federation/EntityStatementFetcher.php @@ -138,6 +138,7 @@ public function fromCache(string $uri): ?EntityStatement * * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. + * @param string ...$additionalCacheKeyElements Additional string elements to be used as cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -146,6 +147,7 @@ public function fromNetwork( HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, array $options = [], bool $shouldCache = true, + string ...$additionalCacheKeyElements, ): EntityStatement { $entityStatement = parent::fromNetwork($uri, $httpMethodsEnum, $options, $shouldCache); diff --git a/src/Federation/TrustMarkFetcher.php b/src/Federation/TrustMarkFetcher.php index 56b532d..8e7a2bd 100644 --- a/src/Federation/TrustMarkFetcher.php +++ b/src/Federation/TrustMarkFetcher.php @@ -118,6 +118,7 @@ public function fromCache(string $uri): ?TrustMark * * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. + * @param string ...$additionalCacheKeyElements Additional string elements to be used as cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -126,6 +127,7 @@ public function fromNetwork( HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, array $options = [], bool $shouldCache = true, + string ...$additionalCacheKeyElements, ): TrustMark { $trustMark = parent::fromNetwork($uri, $httpMethodsEnum, $options, $shouldCache); diff --git a/src/Federation/TrustMarkStatusFetcher.php b/src/Federation/TrustMarkStatusFetcher.php index 9c3f358..a38fe2f 100644 --- a/src/Federation/TrustMarkStatusFetcher.php +++ b/src/Federation/TrustMarkStatusFetcher.php @@ -36,7 +36,7 @@ protected function buildJwsInstance(string $token): TrustMarkStatus public function getExpectedContentTypeHttpHeader(): string { - return ContentTypesEnum::ApplicationTrustMarkJwt->value; + return ContentTypesEnum::ApplicationTrustMarkStatusResponseJwt->value; } @@ -61,31 +61,47 @@ public function fromFederationTrustMarkStatusEndpoint( ['trustMarkStatusEndpoint' => $trustMarkStatusEndpoint, 'trustMarkType' => $trustMark->getType()], ); - $trustMarkStatus = $this->fromNetwork( + return $this->fromNetwork( $trustMarkStatusEndpoint, - HttpMethodsEnum::POST, - [ + options: [ 'form_params' => [ 'trust_mark' => $trustMark->getToken(), ], ], - false, ); + } + + + /** + * Fetch Trust Mark Status from the network. + * + * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html + * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. + * @param string ...$additionalCacheKeyElements Additional string elements to be used as cache key. + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::POST, + array $options = [], + bool $shouldCache = false, + string ...$additionalCacheKeyElements, + ): TrustMarkStatus { + $trustMarkStatus = parent::fromNetwork($uri, $httpMethodsEnum, $options, $shouldCache); if ($trustMarkStatus instanceof TrustMarkStatus) { return $trustMarkStatus; } + // @codeCoverageIgnoreStart $message = 'Unexpected Trust Mark Status instance encountered for network fetch.'; $this->logger?->error( $message, - [ - 'trustMarkStatusEndpoint' => $trustMarkStatusEndpoint, - 'trustMarkType' => $trustMark->getType(), - 'trustMarkStatus' => $trustMarkStatus, - ], + ['uri' => $uri, 'trustMarkStatus' => $trustMarkStatus], ); throw new FetchException($message); + // @codeCoverageIgnoreEnd } } diff --git a/src/Federation/TrustMarkValidator.php b/src/Federation/TrustMarkValidator.php index 11da08e..a639127 100644 --- a/src/Federation/TrustMarkValidator.php +++ b/src/Federation/TrustMarkValidator.php @@ -23,7 +23,7 @@ class TrustMarkValidator * // phpcs:ignore * @param \SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum Default * Trust Mark Status Endpoint Usage Policy to use when none is specified in a particular method. Defaults to - * NotUtilized, meaning that the Trust Mark Status Endpoint will not be used. + * NotUsed, meaning that the Trust Mark Status Endpoint will not be used at all. */ public function __construct( protected readonly TrustChainResolver $trustChainResolver, @@ -34,7 +34,7 @@ public function __construct( protected readonly ?CacheDecorator $cacheDecorator = null, protected readonly ?LoggerInterface $logger = null, // phpcs:ignore - protected readonly TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum = TrustMarkStatusEndpointUsagePolicyEnum::NotUtilized, + protected readonly TrustMarkStatusEndpointUsagePolicyEnum $defaultTrustMarkStatusEndpointUsagePolicyEnum = TrustMarkStatusEndpointUsagePolicyEnum::NotUsed, ) { } @@ -99,6 +99,8 @@ public function isValidationCachedFor( /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @param non-empty-string $trustMarkType * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException @@ -112,6 +114,7 @@ public function fromCacheOrDoForTrustMarkType( EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -131,11 +134,14 @@ public function fromCacheOrDoForTrustMarkType( $trustAnchorEntityConfiguration, $expectedJwtType, $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); } /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @param non-empty-string $trustMarkType * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException @@ -148,6 +154,7 @@ public function doForTrustMarkType( EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { $this->logger?->debug( sprintf( @@ -221,6 +228,7 @@ public function doForTrustMarkType( $trustAnchorEntityConfiguration, $expectedJwtType, $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); $this->logger?->debug( @@ -261,6 +269,8 @@ public function doForTrustMarkType( /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\JwksException @@ -275,6 +285,7 @@ public function fromCacheOrDoForTrustMarksClaimValue( EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -294,11 +305,14 @@ public function fromCacheOrDoForTrustMarksClaimValue( $trustAnchorEntityConfiguration, $expectedJwtType, $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); } /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException * @throws \SimpleSAML\OpenID\Exceptions\JwksException @@ -313,6 +327,7 @@ public function doForTrustMarksClaimValue( EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { $trustMark = $this->validateTrustMarksClaimValue($trustMarksClaimValue, $expectedJwtType); @@ -323,6 +338,7 @@ public function doForTrustMarksClaimValue( $leafEntityConfiguration, $trustAnchorEntityConfiguration, $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); } @@ -394,6 +410,8 @@ public function validateTrustMarksClaimValue( /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException @@ -407,6 +425,7 @@ public function fromCacheOrDoForTrustMark( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -425,11 +444,14 @@ public function fromCacheOrDoForTrustMark( $leafEntityConfiguration, $trustAnchorEntityConfiguration, $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); } /** + * @param non-empty-string[] $additionallyValidTrustMarkStatues Array of additional Trust Mark statuses that are + * considered valid * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkDelegationException * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException @@ -444,6 +466,7 @@ public function doForTrustMark( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); @@ -480,6 +503,7 @@ public function doForTrustMark( $this->validateUsingTrustMarkStatusEndpoint( $trustMark, $trustMarkIssuerEntityConfiguration, + $additionallyValidTrustMarkStatues, ); } diff --git a/src/Jws/JwsFetcher.php b/src/Jws/JwsFetcher.php index 5f1692b..b90cc90 100644 --- a/src/Jws/JwsFetcher.php +++ b/src/Jws/JwsFetcher.php @@ -84,6 +84,7 @@ public function fromCache(string $uri): ?ParsedJws * * @param array $options See https://docs.guzzlephp.org/en/stable/request-options.html * @param bool $shouldCache If true, each successful fetch will be cached, with URI being used as a cache key. + * @param string ...$additionalCacheKeyElements Additional string elements to be used as cache key. * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -92,6 +93,7 @@ public function fromNetwork( HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, array $options = [], bool $shouldCache = true, + string ...$additionalCacheKeyElements, ): ParsedJws { $this->logger?->debug( 'Trying to fetch JWS token from network.', @@ -143,7 +145,7 @@ public function fromNetwork( ) : $this->maxCacheDuration->getInSeconds(); - $this->artifactFetcher->cacheIt($token, $cacheTtl, $uri); + $this->artifactFetcher->cacheIt($token, $cacheTtl, $uri, ...$additionalCacheKeyElements); } $this->logger?->debug('Returning built JWS instance.', ['uri' => $uri, 'token' => $token]); diff --git a/tests/src/Codebooks/TrustMarkStatusEnumTest.php b/tests/src/Codebooks/TrustMarkStatusEnumTest.php index 09e6040..a59bad0 100644 --- a/tests/src/Codebooks/TrustMarkStatusEnumTest.php +++ b/tests/src/Codebooks/TrustMarkStatusEnumTest.php @@ -1,21 +1,23 @@ -assertTrue(TrustMarkStatusEnum::Active->isValid()); - $this->assertFalse(TrustMarkStatusEnum::Expired->isValid());; - $this->assertFalse(TrustMarkStatusEnum::Revoked->isValid());; - $this->assertFalse(TrustMarkStatusEnum::Invalid->isValid()); - } -} +assertTrue(TrustMarkStatusEnum::Active->isValid()); + $this->assertFalse(TrustMarkStatusEnum::Expired->isValid()); + ; + $this->assertFalse(TrustMarkStatusEnum::Revoked->isValid()); + ; + $this->assertFalse(TrustMarkStatusEnum::Invalid->isValid()); + } +} diff --git a/tests/src/Federation/EntityStatementTest.php b/tests/src/Federation/EntityStatementTest.php index e5238e1..fb6d280 100644 --- a/tests/src/Federation/EntityStatementTest.php +++ b/tests/src/Federation/EntityStatementTest.php @@ -387,6 +387,26 @@ public function testCanGetFederationFetchEndpoint(): void } + public function testCanGetFederationTrustMarkStatusEndpoint(): void + { + $payload = $this->validPayload; + $payload['metadata']['federation_entity']['federation_trust_mark_status_endpoint'] = 'uri'; + + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + $this->arrHelperMock->method('getNestedValue') + ->willReturnCallback(fn( + array $array, + string $key, + string $key2, + string $key3, + ): ?string => $array[$key][$key2][$key3] ?? null); + + $this->assertSame('uri', $this->sut()->getFederationTrustMarkStatusEndpoint()); + } + + public function testFederationFetchEndpointIsOptional(): void { $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); diff --git a/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php b/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php index 5950fb6..76f8c8c 100644 --- a/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php +++ b/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php @@ -1,141 +1,142 @@ - 'RS256', - 'typ' => 'trust-mark-status-response+jwt', - 'kid' => 'fsQ45F0D916RdKEeTjta8DYWiodjthouHrVWgOXBrkk', - ]; - - protected array $samplePayload = [ - 'iss' => 'https://www.example.com/trust-mark-issuer', - 'trust_mark' => 'trust-mark-token', - 'iat' => 1759897995, - 'status' => 'active', - ]; - - - protected function setUp(): void - { - $this->signatureMock = $this->createMock(Signature::class); - - $jwsMock = $this->createMock(JWS::class); - $jwsMock->method('getPayload') - ->willReturn('json-payload-string'); // Just so we have non-empty value. - $jwsMock->method('getSignature')->willReturn($this->signatureMock); - - $jwsDecoratorMock = $this->createMock(JwsDecorator::class); - $jwsDecoratorMock->method('jws')->willReturn($jwsMock); - - $this->jwsParserMock = $this->createMock(JwsParser::class); - $this->jwsParserMock->method('parse')->willReturn($jwsDecoratorMock); - - $this->jwsVerifierDecoratorMock = $this->createMock(JwsVerifierDecorator::class); - $this->jwksFactoryMock = $this->createMock(JwksFactory::class); - $this->jwsSerializerManagerMock = $this->createMock(JwsSerializerManagerDecorator::class); - $this->dateIntervalDecoratorMock = $this->createMock(DateIntervalDecorator::class); - - $this->helpersMock = $this->createMock(Helpers::class); - $this->jsonHelperMock = $this->createMock(Helpers\Json::class); - $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); - $typeHelperMock = $this->createMock(Helpers\Type::class); - $this->helpersMock->method('type')->willReturn($typeHelperMock); - - $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); - $typeHelperMock->method('ensureInt')->willReturnArgument(0); - - $this->claimFactoryMock = $this->createMock(ClaimFactory::class); - } - - - protected function sut( - ?JwsParser $jwsParser = null, - ?JwsVerifierDecorator $jwsVerifierDecorator = null, - ?JwksFactory $jwksFactory = null, - ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, - ?DateIntervalDecorator $dateIntervalDecorator = null, - ?Helpers $helpers = null, - ?ClaimFactory $claimFactory = null, - ): TrustMarkStatusFactory { - $jwsParser ??= $this->jwsParserMock; - $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; - $jwksFactory ??= $this->jwksFactoryMock; - $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerMock; - $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; - $helpers ??= $this->helpersMock; - $claimFactory ??= $this->claimFactoryMock; - - return new TrustMarkStatusFactory( - $jwsParser, - $jwsVerifierDecorator, - $jwksFactory, - $jwsSerializerManagerDecorator, - $dateIntervalDecorator, - $helpers, - $claimFactory, - ); - } - - public function testCanCreateInstance(): void - { - $this->assertInstanceOf(TrustMarkStatusFactory::class, $this->sut()); - } - - public function testCanBuildFromToken(): void - { - $this->jsonHelperMock->method('decode')->willReturn($this->samplePayload); - $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); - - $this->assertInstanceOf( - TrustMarkStatus::class, - $this->sut()->fromToken('token'), - ); - } -} + 'RS256', + 'typ' => 'trust-mark-status-response+jwt', + 'kid' => 'fsQ45F0D916RdKEeTjta8DYWiodjthouHrVWgOXBrkk', + ]; + + protected array $samplePayload = [ + 'iss' => 'https://www.example.com/trust-mark-issuer', + 'trust_mark' => 'trust-mark-token', + 'iat' => 1759897995, + 'status' => 'active', + ]; + + + protected function setUp(): void + { + $this->signatureMock = $this->createMock(Signature::class); + + $jwsMock = $this->createMock(JWS::class); + $jwsMock->method('getPayload') + ->willReturn('json-payload-string'); // Just so we have non-empty value. + $jwsMock->method('getSignature')->willReturn($this->signatureMock); + + $jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $jwsDecoratorMock->method('jws')->willReturn($jwsMock); + + $this->jwsParserMock = $this->createMock(JwsParser::class); + $this->jwsParserMock->method('parse')->willReturn($jwsDecoratorMock); + + $this->jwsVerifierDecoratorMock = $this->createMock(JwsVerifierDecorator::class); + $this->jwksFactoryMock = $this->createMock(JwksFactory::class); + $this->jwsSerializerManagerMock = $this->createMock(JwsSerializerManagerDecorator::class); + $this->dateIntervalDecoratorMock = $this->createMock(DateIntervalDecorator::class); + + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $typeHelperMock = $this->createMock(Helpers\Type::class); + $this->helpersMock->method('type')->willReturn($typeHelperMock); + + $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); + $typeHelperMock->method('ensureInt')->willReturnArgument(0); + + $this->claimFactoryMock = $this->createMock(ClaimFactory::class); + } + + + protected function sut( + ?JwsParser $jwsParser = null, + ?JwsVerifierDecorator $jwsVerifierDecorator = null, + ?JwksFactory $jwksFactory = null, + ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, + ?DateIntervalDecorator $dateIntervalDecorator = null, + ?Helpers $helpers = null, + ?ClaimFactory $claimFactory = null, + ): TrustMarkStatusFactory { + $jwsParser ??= $this->jwsParserMock; + $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; + $jwksFactory ??= $this->jwksFactoryMock; + $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerMock; + $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; + $helpers ??= $this->helpersMock; + $claimFactory ??= $this->claimFactoryMock; + + return new TrustMarkStatusFactory( + $jwsParser, + $jwsVerifierDecorator, + $jwksFactory, + $jwsSerializerManagerDecorator, + $dateIntervalDecorator, + $helpers, + $claimFactory, + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(TrustMarkStatusFactory::class, $this->sut()); + } + + + public function testCanBuildFromToken(): void + { + $this->jsonHelperMock->method('decode')->willReturn($this->samplePayload); + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + + $this->assertInstanceOf( + TrustMarkStatus::class, + $this->sut()->fromToken('token'), + ); + } +} diff --git a/tests/src/Federation/TrustMarkStatusFetcherTest.php b/tests/src/Federation/TrustMarkStatusFetcherTest.php new file mode 100644 index 0000000..5a11740 --- /dev/null +++ b/tests/src/Federation/TrustMarkStatusFetcherTest.php @@ -0,0 +1,139 @@ +trustMarkStatusFactoryMock = $this->createMock(TrustMarkStatusFactory::class); + $this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class); + $this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->artifactFetcherMock->method('fromNetwork')->willReturn($this->responseMock); + + $this->entityStatementMock = $this->createMock(EntityStatement::class); + $this->trustMarkMock = $this->createMock(TrustMark::class); + } + + + protected function sut( + ?TrustMarkStatusFactory $trustMarkStatusFactory = null, + ?ArtifactFetcher $artifactFetcher = null, + ?DateIntervalDecorator $maxCacheDuration = null, + ?Helpers $helpers = null, + ?LoggerInterface $logger = null, + ): TrustMarkStatusFetcher { + $trustMarkStatusFactory ??= $this->trustMarkStatusFactoryMock; + $artifactFetcher ??= $this->artifactFetcherMock; + $maxCacheDuration ??= $this->maxCacheDurationMock; + $helpers ??= $this->helpersMock; + $logger ??= $this->loggerMock; + + return new TrustMarkStatusFetcher( + $trustMarkStatusFactory, + $artifactFetcher, + $maxCacheDuration, + $helpers, + $logger, + ); + } + + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(TrustMarkStatusFetcher::class, $this->sut()); + } + + + public function testHasRightExpectedContentTypeHttpHeader(): void + { + $this->assertSame( + ContentTypesEnum::ApplicationTrustMarkStatusResponseJwt->value, + $this->sut()->getExpectedContentTypeHttpHeader(), + ); + } + + + public function testCanFetchFromTrustMarkStatusEndpoint(): void + { + $this->entityStatementMock->expects($this->once()) + ->method('getFederationTrustMarkStatusEndpoint') + ->willReturn('trust-mark-status-uri'); + + $this->artifactFetcherMock->expects($this->never())->method('fromCacheAsString'); + $this->artifactFetcherMock->expects($this->once())->method('fromNetwork'); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->responseMock->method('getHeaderLine') + ->willReturn('application/trust-mark-status-response+jwt'); + + $this->trustMarkStatusFactoryMock->expects($this->once())->method('fromToken'); + + $this->sut()->fromFederationTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->entityStatementMock, + ); + } + + + public function testFetchFromTrustMarkStatusEndpointThrowsIfNoFetchEndpoint(): void + { + $this->entityStatementMock->expects($this->once()) + ->method('getFederationTrustMarkStatusEndpoint') + ->willReturn(null); + + $this->expectException(EntityStatementException::class); + $this->expectExceptionMessage('endpoint'); + + $this->sut()->fromFederationTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->entityStatementMock, + ); + } +} diff --git a/tests/src/Federation/TrustMarkStatusTest.php b/tests/src/Federation/TrustMarkStatusTest.php new file mode 100644 index 0000000..531f936 --- /dev/null +++ b/tests/src/Federation/TrustMarkStatusTest.php @@ -0,0 +1,172 @@ + 'https://www.agid.gov.it', + 'iat' => 1734016912, + // phpcs:ignore + 'trust_mark' => 'trust-mark-string', + 'status' => 'active', + ]; + + protected array $sampleHeader = [ + 'alg' => 'RS256', + 'typ' => 'trust-mark-status-response+jwt', + 'kid' => 'fsQ45F0D916RdKEeTjta8DYWiodjthouHrVWgOXBrkk', + ]; + + + protected function setUp(): void + { + $this->signatureMock = $this->createMock(Signature::class); + + $jwsMock = $this->createMock(JWS::class); + $jwsMock->method('getPayload') + ->willReturn('json-payload-string'); // Just so we have non-empty value. + $jwsMock->method('getSignature')->willReturn($this->signatureMock); + + $this->jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $this->jwsDecoratorMock->method('jws')->willReturn($jwsMock); + + $this->jwsVerifierDecoratorMock = $this->createMock(JwsVerifierDecorator::class); + $this->jwksFactoryMock = $this->createMock(JwksFactory::class); + $this->jwsSerializerManagerDecoratorMock = $this->createMock(JwsSerializerManagerDecorator::class); + $this->dateIntervalDecoratorMock = $this->createMock(DateIntervalDecorator::class); + + $this->helpersMock = $this->createMock(Helpers::class); + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + $typeHelperMock = $this->createMock(Helpers\Type::class); + $this->helpersMock->method('type')->willReturn($typeHelperMock); + + $typeHelperMock->method('ensureNonEmptyString')->willReturnArgument(0); + $typeHelperMock->method('ensureInt')->willReturnArgument(0); + + $this->claimFactoryMock = $this->createMock(ClaimFactory::class); + } + + + protected function sut( + ?JwsDecorator $jwsDecorator = null, + ?JwsVerifierDecorator $jwsVerifierDecorator = null, + ?JwksFactory $jwksFactory = null, + ?JwsSerializerManagerDecorator $jwsSerializerManagerDecorator = null, + ?DateIntervalDecorator $dateIntervalDecorator = null, + ?Helpers $helpers = null, + ?ClaimFactory $claimFactory = null, + ): TrustMarkStatus { + $jwsDecorator ??= $this->jwsDecoratorMock; + $jwsVerifierDecorator ??= $this->jwsVerifierDecoratorMock; + $jwksFactory ??= $this->jwksFactoryMock; + $jwsSerializerManagerDecorator ??= $this->jwsSerializerManagerDecoratorMock; + $dateIntervalDecorator ??= $this->dateIntervalDecoratorMock; + $helpers ??= $this->helpersMock; + $claimFactory ??= $this->claimFactoryMock; + + return new TrustMarkStatus( + $jwsDecorator, + $jwsVerifierDecorator, + $jwksFactory, + $jwsSerializerManagerDecorator, + $dateIntervalDecorator, + $helpers, + $claimFactory, + ); + } + + + public function testCanCreateInstance(): void + { + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($this->samplePayload); + + $this->assertInstanceOf( + TrustMarkStatus::class, + $this->sut(), + ); + } + + + public function testThrowsForMissingTrustMark(): void + { + $payload = $this->samplePayload; + unset($payload['trust_mark']); + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Trust Mark'); + + $this->sut(); + } + + + public function testThrowsForMissingStatus(): void + { + $payload = $this->samplePayload; + unset($payload['status']); + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Status'); + + $this->sut(); + } + + + public function testThrowsForInvalidTypeHeader(): void + { + $header = $this->sampleHeader; + $header['typ'] = 'invalid'; + $this->signatureMock->method('getProtectedHeader')->willReturn($header); + $this->jsonHelperMock->method('decode')->willReturn($this->samplePayload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Type'); + + $this->sut(); + } +} diff --git a/tests/src/Federation/TrustMarkValidatorTest.php b/tests/src/Federation/TrustMarkValidatorTest.php index fbaa427..2f053d4 100644 --- a/tests/src/Federation/TrustMarkValidatorTest.php +++ b/tests/src/Federation/TrustMarkValidatorTest.php @@ -5,9 +5,12 @@ namespace SimpleSAML\Test\OpenID\Federation; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; +use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEndpointUsagePolicyEnum; +use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEnum; use SimpleSAML\OpenID\Decorators\CacheDecorator; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\TrustMarkException; @@ -23,10 +26,12 @@ use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Federation\TrustMark; use SimpleSAML\OpenID\Federation\TrustMarkDelegation; +use SimpleSAML\OpenID\Federation\TrustMarkStatus; use SimpleSAML\OpenID\Federation\TrustMarkStatusFetcher; use SimpleSAML\OpenID\Federation\TrustMarkValidator; #[CoversClass(TrustMarkValidator::class)] +#[UsesClass(TrustMarkStatusEnum::class)] final class TrustMarkValidatorTest extends TestCase { protected MockObject $trustChainResolverMock; @@ -63,6 +68,8 @@ final class TrustMarkValidatorTest extends TestCase protected MockObject $trustMarkIssuersClaimValueMock; + protected MockObject $trustMarkStatusMock; + protected function setUp(): void { @@ -91,6 +98,8 @@ protected function setUp(): void $this->trustMarkIssuersClaimBagMock = $this->createMock(TrustMarkIssuersClaimBag::class); $this->trustMarkIssuersClaimValueMock = $this->createMock(TrustMarkIssuersClaimValue::class); + + $this->trustMarkStatusMock = $this->createMock(TrustMarkStatus::class); } @@ -462,6 +471,31 @@ public function testDoForTrustMarkCanHandleTrustAnchorAsTrustMarkIssuer(): void } + public function testDoForTrustMarkCanUseTrustMarkStatusEndpoint(): void + { + $this->cacheDecoratorMock->expects($this->never())->method('get'); + $this->trustMarkMock->method('getTrustMarkType')->willReturn('trustMarkType'); + $this->trustMarkMock->method('getSubject')->willReturn('leafEntityId'); + + $this->trustMarkStatusMock->expects($this->atLeastOnce()) + ->method('getStatus') + ->willReturn('active'); + + $this->trustMarkStatusFetcherMock->expects($this->once()) + ->method('fromFederationTrustMarkStatusEndpoint') + ->willReturn($this->trustMarkStatusMock); + + $this->cacheDecoratorMock->expects($this->once())->method('set'); + + $this->sut()->doForTrustMark( + $this->trustMarkMock, + $this->leafEntityConfigurationMock, + $this->trustAnchorConfigurationMock, + TrustMarkStatusEndpointUsagePolicyEnum::Required, + ); + } + + public function testValidateSubjectClaimThrowsForInvalidSubject(): void { $this->trustMarkMock->method('getSubject')->willReturn('invalidSubject'); @@ -830,4 +864,90 @@ public function testValidateTrustMarkIssuersPassesForIssuerAdvertisedByTrustAnch $this->trustAnchorConfigurationMock, ); } + + + public function testShouldUseTrustMarkStatusForNonExpiringTrustMarks(): void + { + $trustMarkIssuerEntityConfiguration = $this->createMock(EntityStatement::class); + + $nonExpiringTrustMark = $this->createMock(TrustMark::class); + $nonExpiringTrustMark->method('getExpirationTime')->willReturn(null); + $this->assertTrue( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $nonExpiringTrustMark, + $trustMarkIssuerEntityConfiguration, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredForNonExpiringTrustMarksOnly, + ), + ); + + + $expiringTrustMark = $this->createMock(TrustMark::class); + $expiringTrustMark->method('getExpirationTime')->willReturn(time() + 100); + $this->assertFalse( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $expiringTrustMark, + $trustMarkIssuerEntityConfiguration, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredForNonExpiringTrustMarksOnly, + ), + ); + } + + + public function testShouldUseTrustMarkStatusWhenEndpointIsAvailable(): void + { + $trustMarkIssuerEntityConfigurationWithEndpoint = $this->createMock(EntityStatement::class); + $trustMarkIssuerEntityConfigurationWithEndpoint->method('getFederationTrustMarkStatusEndpoint') + ->willReturn('https://example.com/trust-mark-status'); + + $this->assertTrue( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $this->trustMarkMock, + $trustMarkIssuerEntityConfigurationWithEndpoint, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvided, + ), + ); + + $trustMarkIssuerEntityConfigurationWithoutEndpoint = $this->createMock(EntityStatement::class); + $trustMarkIssuerEntityConfigurationWithoutEndpoint->method('getFederationTrustMarkStatusEndpoint') + ->willReturn(null); + + $this->assertFalse( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $this->trustMarkMock, + $trustMarkIssuerEntityConfigurationWithoutEndpoint, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvided, + ), + ); + } + + + public function testShouldUseTrustMarkStatusForNonExpiringWhenEndpointIsAvailable(): void + { + $trustMarkIssuerEntityConfigurationWithEndpoint = $this->createMock(EntityStatement::class); + $trustMarkIssuerEntityConfigurationWithEndpoint->method('getFederationTrustMarkStatusEndpoint') + ->willReturn('https://example.com/trust-mark-status'); + + $nonExpiringTrustMark = $this->createMock(TrustMark::class); + $nonExpiringTrustMark->method('getExpirationTime')->willReturn(null); + + $this->assertTrue( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $nonExpiringTrustMark, + $trustMarkIssuerEntityConfigurationWithEndpoint, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, + ), + ); + + $trustMarkIssuerEntityConfigurationWithoutEndpoint = $this->createMock(EntityStatement::class); + $trustMarkIssuerEntityConfigurationWithoutEndpoint->method('getFederationTrustMarkStatusEndpoint') + ->willReturn(null); + + $this->assertFalse( + $this->sut()->shouldUseTrustMarkStatusEndpoint( + $this->trustMarkMock, + $trustMarkIssuerEntityConfigurationWithoutEndpoint, + TrustMarkStatusEndpointUsagePolicyEnum::RequiredIfEndpointProvidedForNonExpiringTrustMarksOnly, + ), + ); + } } From 7a0f6d99b7f3586182309b013aece4942badd508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Ivan=C4=8Di=C4=87?= Date: Mon, 10 Nov 2025 10:32:01 +0100 Subject: [PATCH 3/3] Enable validating TMs using TMS endpoint --- .../src/Federation/TrustMarkValidatorTest.php | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/src/Federation/TrustMarkValidatorTest.php b/tests/src/Federation/TrustMarkValidatorTest.php index 2f053d4..4727ae1 100644 --- a/tests/src/Federation/TrustMarkValidatorTest.php +++ b/tests/src/Federation/TrustMarkValidatorTest.php @@ -13,7 +13,9 @@ use SimpleSAML\OpenID\Codebooks\TrustMarkStatusEnum; use SimpleSAML\OpenID\Decorators\CacheDecorator; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; +use SimpleSAML\OpenID\Exceptions\FetchException; use SimpleSAML\OpenID\Exceptions\TrustMarkException; +use SimpleSAML\OpenID\Exceptions\TrustMarkStatusException; use SimpleSAML\OpenID\Federation\Claims\TrustMarkIssuersClaimBag; use SimpleSAML\OpenID\Federation\Claims\TrustMarkIssuersClaimValue; use SimpleSAML\OpenID\Federation\Claims\TrustMarkOwnersClaimBag; @@ -70,6 +72,8 @@ final class TrustMarkValidatorTest extends TestCase protected MockObject $trustMarkStatusMock; + protected MockObject $trustMarkIssuerConfigurationMock; + protected function setUp(): void { @@ -100,6 +104,9 @@ protected function setUp(): void $this->trustMarkIssuersClaimValueMock = $this->createMock(TrustMarkIssuersClaimValue::class); $this->trustMarkStatusMock = $this->createMock(TrustMarkStatus::class); + + $this->trustMarkIssuerConfigurationMock = $this->createMock(EntityStatement::class); + $this->trustMarkIssuerConfigurationMock->method('getIssuer')->willReturn('trustMarkIssuerId'); } @@ -950,4 +957,87 @@ public function testShouldUseTrustMarkStatusForNonExpiringWhenEndpointIsAvailabl ), ); } + + + public function testValidateUsingTrustMarkStatusEndpointThrowsOnFetchError(): void + { + $this->trustMarkStatusFetcherMock->method('fromFederationTrustMarkStatusEndpoint') + ->willThrowException(new FetchException('error')); + + $this->expectException(TrustMarkException::class); + $this->expectExceptionMessage('Error fetching Trust Mark Status'); + + $this->sut()->validateUsingTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->trustMarkIssuerConfigurationMock, + ); + } + + + public function testValidateUsingTrustMarkStatusEndpointThrowsOnInvalidTrustMarkStatus(): void + { + $this->trustMarkStatusFetcherMock->method('fromFederationTrustMarkStatusEndpoint') + ->willReturn($this->trustMarkStatusMock); + $this->trustMarkStatusMock->method('getStatus') + ->willReturn('invalid'); // From the spec + + $this->expectException(TrustMarkStatusException::class); + $this->expectExceptionMessage('not valid'); + + $this->sut()->validateUsingTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->trustMarkIssuerConfigurationMock, + ); + } + + + public function testValidateUsingTrustMarkStatusThrowsOnCustomStatus(): void + { + $this->trustMarkStatusFetcherMock->method('fromFederationTrustMarkStatusEndpoint') + ->willReturn($this->trustMarkStatusMock); + $this->trustMarkStatusMock->method('getStatus') + ->willReturn('custom-status'); // Not from the spec + + $this->expectException(TrustMarkStatusException::class); + $this->expectExceptionMessage('not valid'); + + $this->sut()->validateUsingTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->trustMarkIssuerConfigurationMock, + ); + } + + + public function testValidateUsingTrustMarkStatusPassesOnCustomValidTrustMarkStatus(): void + { + $this->trustMarkStatusFetcherMock->method('fromFederationTrustMarkStatusEndpoint') + ->willReturn($this->trustMarkStatusMock); + $this->trustMarkStatusMock->expects($this->atLeastOnce()) + ->method('getStatus') + ->willReturn('custom-status'); // Not from the spec + + $this->sut()->validateUsingTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->trustMarkIssuerConfigurationMock, + ['custom-status'], + ); + } + + + public function testValidateUsingTrustMarkStatusThrowsOnInvalidCustomStatus(): void + { + $this->trustMarkStatusFetcherMock->method('fromFederationTrustMarkStatusEndpoint') + ->willReturn($this->trustMarkStatusMock); + $this->trustMarkStatusMock->method('getStatus') + ->willReturn('invalid-custom-status'); // Not from the spec + + $this->expectException(TrustMarkStatusException::class); + $this->expectExceptionMessage('not valid'); + + $this->sut()->validateUsingTrustMarkStatusEndpoint( + $this->trustMarkMock, + $this->trustMarkIssuerConfigurationMock, + ['custom-status'], + ); + } }