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/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..06e27bf 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 ApplicationTrustMarkStatusResponseJwt = '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..b6063b7 --- /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..5591a2b 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,22 @@ 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. + * @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): EntityStatement - { - $entityStatement = parent::fromNetwork($uri); + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + string ...$additionalCacheKeyElements, + ): 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..8e7a2bd 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,22 @@ 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. + * @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): TrustMark - { - $trustMark = parent::fromNetwork($uri); + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + string ...$additionalCacheKeyElements, + ): 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..a38fe2f --- /dev/null +++ b/src/Federation/TrustMarkStatusFetcher.php @@ -0,0 +1,107 @@ +parsedJwsFactory->fromToken($token); + } + + + public function getExpectedContentTypeHttpHeader(): string + { + return ContentTypesEnum::ApplicationTrustMarkStatusResponseJwt->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()], + ); + + return $this->fromNetwork( + $trustMarkStatusEndpoint, + options: [ + 'form_params' => [ + 'trust_mark' => $trustMark->getToken(), + ], + ], + ); + } + + + /** + * 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, + ['uri' => $uri, 'trustMarkStatus' => $trustMarkStatus], + ); + + throw new FetchException($message); + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Federation/TrustMarkValidator.php b/src/Federation/TrustMarkValidator.php index 23e29da..a639127 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 + * NotUsed, meaning that the Trust Mark Status Endpoint will not be used at all. + */ 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::NotUsed, ) { } @@ -87,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 @@ -99,6 +113,8 @@ public function fromCacheOrDoForTrustMarkType( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -110,16 +126,22 @@ public function fromCacheOrDoForTrustMarkType( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMarkType( $trustMarkType, $leafEntityConfiguration, $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 @@ -131,6 +153,8 @@ public function doForTrustMarkType( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { $this->logger?->debug( sprintf( @@ -183,6 +207,8 @@ public function doForTrustMarkType( ), ); + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + foreach ($trustMarksClaimValues as $idx => $trustMarksClaimValue) { $this->logger?->debug( sprintf( @@ -201,6 +227,8 @@ public function doForTrustMarkType( $leafEntityConfiguration, $trustAnchorEntityConfiguration, $expectedJwtType, + $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); $this->logger?->debug( @@ -241,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 @@ -254,6 +284,8 @@ public function fromCacheOrDoForTrustMarksClaimValue( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -265,16 +297,22 @@ public function fromCacheOrDoForTrustMarksClaimValue( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMarksClaimValue( $trustMarksClaimValue, $leafEntityConfiguration, $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 @@ -288,13 +326,19 @@ public function doForTrustMarksClaimValue( EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, JwtTypesEnum $expectedJwtType = JwtTypesEnum::TrustMarkJwt, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { $trustMark = $this->validateTrustMarksClaimValue($trustMarksClaimValue, $expectedJwtType); + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMark( $trustMark, $leafEntityConfiguration, $trustAnchorEntityConfiguration, + $trustMarkStatusEndpointUsagePolicyEnum, + $additionallyValidTrustMarkStatues, ); } @@ -366,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 @@ -378,6 +424,8 @@ public function fromCacheOrDoForTrustMark( TrustMark $trustMark, EntityStatement $leafEntityConfiguration, EntityStatement $trustAnchorEntityConfiguration, + ?TrustMarkStatusEndpointUsagePolicyEnum $trustMarkStatusEndpointUsagePolicyEnum = null, + array $additionallyValidTrustMarkStatues = [], ): void { if ( $this->isValidationCachedFor( @@ -389,15 +437,21 @@ public function fromCacheOrDoForTrustMark( return; } + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->doForTrustMark( $trustMark, $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 @@ -405,18 +459,24 @@ 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, + array $additionallyValidTrustMarkStatues = [], ): void { + $trustMarkStatusEndpointUsagePolicyEnum ??= $this->getDefaultTrustMarkStatusEndpointUsagePolicyEnum(); + $this->logger?->debug( 'Validating Trust Mark.', [ 'trustMarkPayload' => $trustMark->getPayload(), 'leafEntityConfigurationPayload' => $leafEntityConfiguration->getPayload(), 'trustAnchorEntityConfigurationPayload' => $trustAnchorEntityConfiguration->getPayload(), + 'trustMarkStatusEndpointUsagePolicyEnum' => $trustMarkStatusEndpointUsagePolicyEnum->name, ], ); @@ -433,6 +493,20 @@ public function doForTrustMark( $trustAnchorEntityConfiguration, )->getResolvedLeaf(); + if ( + $this->shouldUseTrustMarkStatusEndpoint( + $trustMark, + $trustMarkIssuerEntityConfiguration, + $trustMarkStatusEndpointUsagePolicyEnum, + ) + ) { + $this->validateUsingTrustMarkStatusEndpoint( + $trustMark, + $trustMarkIssuerEntityConfiguration, + $additionallyValidTrustMarkStatues, + ); + } + $this->validateTrustMarkSignature($trustMark, $trustMarkIssuerEntityConfiguration); $this->validateTrustMarkDelegation($trustMark, $trustAnchorEntityConfiguration); @@ -816,4 +890,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..b90cc90 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,27 @@ 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. + * @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): ParsedJws - { + public function fromNetwork( + string $uri, + HttpMethodsEnum $httpMethodsEnum = HttpMethodsEnum::GET, + array $options = [], + bool $shouldCache = true, + string ...$additionalCacheKeyElements, + ): 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 +135,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, ...$additionalCacheKeyElements); + } $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..a59bad0 --- /dev/null +++ b/tests/src/Codebooks/TrustMarkStatusEnumTest.php @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..76f8c8c --- /dev/null +++ b/tests/src/Federation/Factories/TrustMarkStatusFactoryTest.php @@ -0,0 +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'), + ); + } +} 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 81184f6..4727ae1 100644 --- a/tests/src/Federation/TrustMarkValidatorTest.php +++ b/tests/src/Federation/TrustMarkValidatorTest.php @@ -5,12 +5,17 @@ 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\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; @@ -23,9 +28,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; @@ -34,6 +42,8 @@ final class TrustMarkValidatorTest extends TestCase protected MockObject $trustMarkDelegationFactoryMock; + protected MockObject $trustMarkStatusFetcherMock; + protected MockObject $maxCacheDurationDecoratorMock; protected MockObject $cacheDecoratorMock; @@ -60,12 +70,17 @@ final class TrustMarkValidatorTest extends TestCase protected MockObject $trustMarkIssuersClaimValueMock; + protected MockObject $trustMarkStatusMock; + + protected MockObject $trustMarkIssuerConfigurationMock; + 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); @@ -87,6 +102,11 @@ protected function setUp(): void $this->trustMarkIssuersClaimBagMock = $this->createMock(TrustMarkIssuersClaimBag::class); $this->trustMarkIssuersClaimValueMock = $this->createMock(TrustMarkIssuersClaimValue::class); + + $this->trustMarkStatusMock = $this->createMock(TrustMarkStatus::class); + + $this->trustMarkIssuerConfigurationMock = $this->createMock(EntityStatement::class); + $this->trustMarkIssuerConfigurationMock->method('getIssuer')->willReturn('trustMarkIssuerId'); } @@ -94,6 +114,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 +122,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 +131,7 @@ protected function sut( $trustChainResolver, $trustMarkFactory, $trustMarkDelegationFactory, + $trustMarkStatusFetcher, $maxCacheDurationDecorator, $cacheDecorator, $logger, @@ -170,6 +193,7 @@ public function testIsValidationCachedForReturnsFalseIfNoCacheInstance(): void $this->trustChainResolverMock, $this->trustMarkFactoryMock, $this->trustMarkDelegationFactoryMock, + $this->trustMarkStatusFetcherMock, $this->maxCacheDurationDecoratorMock, ); @@ -454,6 +478,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'); @@ -795,7 +844,6 @@ public function testValidateTrustMarkIssuersThrowsForIssuerNotAdvertisedByTrustA $this->expectException(TrustMarkException::class); $this->expectExceptionMessage('not issued by any'); - ; $this->sut()->validateTrustMarkIssuers( $this->trustMarkMock, @@ -823,4 +871,173 @@ 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, + ), + ); + } + + + 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'], + ); + } } 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;