diff --git a/README.md b/README.md index 2202927..2ce975e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![Build Status](https://github.com/simplesamlphp/openid/actions/workflows/php.yml/badge.svg)](https://github.com/simplesamlphp/openid/actions/workflows/php.yml) [![Coverage Status](https://codecov.io/gh/simplesamlphp/openid/branch/master/graph/badge.svg)](https://app.codecov.io/gh/simplesamlphp/openid) -WARNING: this library is under heavy development and should not be used in production! +The library is under development, and you can expect braking changes along the way. -This library provides some common tools that you might find useful when working with OpenID family of specifications. +The library provides some common tools that you might find useful when working with OpenID family of specifications. ## Installation @@ -15,7 +15,7 @@ Library can be installed by using Composer: composer require simplesamlphp/openid ``` -## OpenID Federation +## OpenID Federation (draft 41) The initial functionality of the library revolves around the OpenID Federation specification. To use it, create an instance of the class `\SimpleSAML\OpenID\Federation` diff --git a/composer.json b/composer.json index 5ae7e17..02a9aec 100644 --- a/composer.json +++ b/composer.json @@ -32,11 +32,12 @@ "web-token/jwt-library": "^3.4 || ^4.0" }, "require-dev": { + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10", + "rector/rector": "^2.0", + "simplesamlphp/simplesamlphp-test-framework": "^1", "squizlabs/php_codesniffer": "^3", - "vimeo/psalm": "^5", - "rector/rector": "^1 || ^2", - "simplesamlphp/simplesamlphp-test-framework": "^1" + "vimeo/psalm": "^5" }, "config": { "sort-packages": true, @@ -50,6 +51,7 @@ "pre-commit": [ "vendor/bin/phpcs -p", "vendor/bin/psalm --no-cache", + "vendor/bin/rector --dry-run", "vendor/bin/phpunit --no-coverage" ] } diff --git a/phpstan-dev.neon b/phpstan-dev.neon new file mode 100644 index 0000000..322f27c --- /dev/null +++ b/phpstan-dev.neon @@ -0,0 +1,6 @@ + +parameters: + level: 0 + paths: + - tests + tmpDir: build/phpstan \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..68ce09f --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ + +parameters: + level: 6 + paths: + - src + tmpDir: build/phpstan \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..e0611f0 --- /dev/null +++ b/rector.php @@ -0,0 +1,17 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + // uncomment to reach your current PHP version + ->withPhpSets() + ->withTypeCoverageLevel(1000) + ->withDeadCodeLevel(1000) + ->withCodeQualityLevel(1000) + ; diff --git a/src/Algorithms/SignatureAlgorithmBag.php b/src/Algorithms/SignatureAlgorithmBag.php index ee4a692..6483937 100644 --- a/src/Algorithms/SignatureAlgorithmBag.php +++ b/src/Algorithms/SignatureAlgorithmBag.php @@ -4,6 +4,8 @@ namespace SimpleSAML\OpenID\Algorithms; +use Jose\Component\Signature\Algorithm\SignatureAlgorithm; + class SignatureAlgorithmBag { /** @var \SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum[] */ @@ -33,9 +35,9 @@ public function getAll(): array public function getAllInstances(): array { return array_map( - function (SignatureAlgorithmEnum $signatureAlgorithmEnum) { - return $signatureAlgorithmEnum->instance(); - }, + fn( + SignatureAlgorithmEnum $signatureAlgorithmEnum, + ): SignatureAlgorithm => $signatureAlgorithmEnum->instance(), $this->getAll(), ); } diff --git a/src/Codebooks/ContentTypesEnum.php b/src/Codebooks/ContentTypesEnum.php index 40a3ce9..4bec4bc 100644 --- a/src/Codebooks/ContentTypesEnum.php +++ b/src/Codebooks/ContentTypesEnum.php @@ -6,5 +6,6 @@ enum ContentTypesEnum: string { + case ApplicationJwt = 'application/jwt'; case ApplicationEntityStatementJwt = 'application/entity-statement+jwt'; } diff --git a/src/Codebooks/MetadataPolicyOperatorsEnum.php b/src/Codebooks/MetadataPolicyOperatorsEnum.php index 6a77317..9d16fe6 100644 --- a/src/Codebooks/MetadataPolicyOperatorsEnum.php +++ b/src/Codebooks/MetadataPolicyOperatorsEnum.php @@ -20,11 +20,17 @@ enum MetadataPolicyOperatorsEnum: string case SupersetOf = 'superset_of'; case Essential = 'essential'; + /** + * @return string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); } + /** + * @return string[] + */ public function getSupportedOperatorValueTypes(): array { return match ($this) { @@ -54,6 +60,9 @@ public function getSupportedOperatorValueTypes(): array }; } + /** + * @return string[] + */ public function getSupportedParameterValueTypes(): array { return match ($this) { @@ -79,6 +88,7 @@ public function getSupportedParameterValueTypes(): array } /** + * @return string[] * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException */ public function getSupportedOperatorContainedValueTypes(): array @@ -96,6 +106,7 @@ public function getSupportedOperatorContainedValueTypes(): array } /** + * @return string[] * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException */ public function getSupportedParameterContainedValueTypes(): array @@ -113,19 +124,25 @@ public function getSupportedParameterContainedValueTypes(): array }; } + /** + * @phpstan-ignore missingType.iterableValue (We can handle mixed type using array_diff) + */ public function isValueSubsetOf(mixed $value, array $superset): bool { $value = is_array($value) ? $value : [$value]; - return empty(array_diff($value, $superset)); + return array_diff($value, $superset) === []; } + /** + * @phpstan-ignore missingType.iterableValue (We can handle mixed type using array_diff) + */ public function isValueSupersetOf(mixed $value, array $subset): bool { $value = is_array($value) ? $value : [$value]; // Like subset, but from different perspective. - return empty(array_diff($subset, $value)); + return array_diff($subset, $value) === []; } /** @@ -178,6 +195,9 @@ public function isParameterValueTypeSupported(mixed $parameterValue): bool return true; } + /** + * @return string[] + */ public function getSupportedOperatorCombinations(): array { return [ @@ -226,16 +246,19 @@ public function getSupportedOperatorCombinations(): array ]; } + /** + * @param string[] $operatorKeys + */ public function isOperatorCombinationSupported(array $operatorKeys): bool { - return empty(array_diff($operatorKeys, $this->getSupportedOperatorCombinations())); + return array_diff($operatorKeys, $this->getSupportedOperatorCombinations()) === []; } /** * Validate general parameter operation rules like operator combinations and operator value type. * - * @param array $parameterOperations - * @return void + * @param array $parameterOperations + * * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException */ public static function validateGeneralParameterOperationRules(array $parameterOperations): void @@ -253,27 +276,30 @@ public static function validateGeneralParameterOperationRules(array $parameterOp $operatorValue = $parameterOperations[$metadataPolicyOperatorsEnum->value]; // Check common policy resolving rules for each supported operator. // If operator value type is not supported, throw. - $metadataPolicyOperatorsEnum->isOperatorValueTypeSupported($operatorValue) || - throw new MetadataPolicyException( - sprintf( - 'Unsupported operator value type (or contained value type) encountered for %s: %s', - $metadataPolicyOperatorsEnum->value, - var_export($operatorValue, true), - ), - ); + if (!$metadataPolicyOperatorsEnum->isOperatorValueTypeSupported($operatorValue)) { + throw new MetadataPolicyException( + sprintf( + 'Unsupported operator value type (or contained value type) encountered for %s: %s', + $metadataPolicyOperatorsEnum->value, + var_export($operatorValue, true), + ), + ); + } // If operator combination is not allowed, throw. - $metadataPolicyOperatorsEnum->isOperatorCombinationSupported($parameterOperatorKeys) || - throw new MetadataPolicyException( - sprintf( - 'Unsupported operator combination encountered for %s: %s', - $metadataPolicyOperatorsEnum->value, - implode(', ', $parameterOperatorKeys), - ), - ); + if (!$metadataPolicyOperatorsEnum->isOperatorCombinationSupported($parameterOperatorKeys)) { + throw new MetadataPolicyException( + sprintf( + 'Unsupported operator combination encountered for %s: %s', + $metadataPolicyOperatorsEnum->value, + implode(', ', $parameterOperatorKeys), + ), + ); + } } } /** + * @param array $parameterOperations * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException */ public static function validateSpecificParameterOperationRules(array $parameterOperations): void @@ -291,25 +317,26 @@ public static function validateSpecificParameterOperationRules(array $parameterO // No special resolving rules for operator 'value', continue with 'add'. if ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Add) { - /** @var array $operatorValue We ensured this is array. */ + /** @var array $operatorValue We ensured this is array. */ // If add is combined with subset_of, the values of add MUST be a subset of the values of // subset_of. if ( in_array(MetadataPolicyOperatorsEnum::SubsetOf->value, $parameterOperatorKeys, true) ) { - /** @var array $superset We ensured this is array. */ + /** @var array $superset We ensured this is array. */ $superset = $parameterOperations[ MetadataPolicyOperatorsEnum::SubsetOf->value ]; - (MetadataPolicyOperatorsEnum::Add->isValueSubsetOf($operatorValue, $superset)) || - throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not subset of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($superset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::Add->isValueSubsetOf($operatorValue, $superset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not subset of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($superset, true), + ), + ); + } } // If add is combined with superset_of, the values of add MUST be a superset of the values // of superset_of. @@ -320,57 +347,60 @@ public static function validateSpecificParameterOperationRules(array $parameterO true, ) ) { - /** @var array $subset We ensured this is array. */ + /** @var array $subset We ensured this is array. */ $subset = $parameterOperations[ MetadataPolicyOperatorsEnum::SupersetOf->value ]; - (MetadataPolicyOperatorsEnum::Add->isValueSupersetOf($operatorValue, $subset)) - || throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not superset of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($subset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::Add->isValueSupersetOf($operatorValue, $subset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not superset of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($subset, true), + ), + ); + } } } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Default) { // If default is combined with one_of, the default value MUST be among the one_of values. if ( in_array(MetadataPolicyOperatorsEnum::OneOf->value, $parameterOperatorKeys, true) ) { - /** @var array $superset We ensured this is array. */ + /** @var array $superset We ensured this is array. */ $superset = $parameterOperations[ MetadataPolicyOperatorsEnum::OneOf->value ]; - (MetadataPolicyOperatorsEnum::OneOf->isValueSubsetOf($operatorValue, $superset)) || - throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not one of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($superset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::OneOf->isValueSubsetOf($operatorValue, $superset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not one of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($superset, true), + ), + ); + } } // If default is combined with subset_of, the value of default MUST be a subset of the // values of subset_of. if ( in_array(MetadataPolicyOperatorsEnum::SubsetOf->value, $parameterOperatorKeys, true) ) { - /** @var array $superset We ensured this is array. */ + /** @var array $superset We ensured this is array. */ $superset = $parameterOperations[ MetadataPolicyOperatorsEnum::SubsetOf->value ]; - (MetadataPolicyOperatorsEnum::Default->isValueSubsetOf($operatorValue, $superset)) || - throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not subset of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($superset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::Default->isValueSubsetOf($operatorValue, $superset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not subset of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($superset, true), + ), + ); + } } // If default is combined with superset_of, the values of default MUST be a superset of // the values of superset_of. @@ -381,19 +411,20 @@ public static function validateSpecificParameterOperationRules(array $parameterO true, ) ) { - /** @var array $subset We ensured this is array. */ + /** @var array $subset We ensured this is array. */ $subset = $parameterOperations[ MetadataPolicyOperatorsEnum::SupersetOf->value ]; - (MetadataPolicyOperatorsEnum::Default->isValueSupersetOf($operatorValue, $subset)) - || throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not superset of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($subset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::Default->isValueSupersetOf($operatorValue, $subset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not superset of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($subset, true), + ), + ); + } } // Operator one_of has special rule when combined with default, but we already handled that @@ -410,19 +441,20 @@ public static function validateSpecificParameterOperationRules(array $parameterO true, ) ) { - /** @var array $subset We ensured this is array. */ + /** @var array $subset We ensured this is array. */ $subset = $parameterOperations[ MetadataPolicyOperatorsEnum::SupersetOf->value ]; - (MetadataPolicyOperatorsEnum::SubsetOf->isValueSupersetOf($operatorValue, $subset)) - || throw new MetadataPolicyException( - sprintf( - 'Operator %s, value %s is not superset of %s.', - $metadataPolicyOperatorEnum->value, - var_export($operatorValue, true), - var_export($subset, true), - ), - ); + if (!MetadataPolicyOperatorsEnum::SubsetOf->isValueSupersetOf($operatorValue, $subset)) { + throw new MetadataPolicyException( + sprintf( + 'Operator %s, value %s is not superset of %s.', + $metadataPolicyOperatorEnum->value, + var_export($operatorValue, true), + var_export($subset, true), + ), + ); + } } // Operator superset_of has special rules when combined with add, default and subset_of, @@ -437,14 +469,15 @@ public static function validateSpecificParameterOperationRules(array $parameterO */ public function validateMetadataParameterValueType(mixed $parameterValue, string $parameterName): void { - $this->isParameterValueTypeSupported($parameterValue) || - throw new MetadataPolicyException( - sprintf( - 'Unsupported parameter %s value type (or contained value type) encountered for %s: %s', - $parameterName, - $this->value, - var_export($parameterValue, true), - ), - ); + if (!$this->isParameterValueTypeSupported($parameterValue)) { + throw new MetadataPolicyException( + sprintf( + 'Unsupported parameter %s value type (or contained value type) encountered for %s: %s', + $parameterName, + $this->value, + var_export($parameterValue, true), + ), + ); + } } } diff --git a/src/Core.php b/src/Core.php index d740121..2bb4470 100644 --- a/src/Core.php +++ b/src/Core.php @@ -23,12 +23,19 @@ class Core { - protected DateIntervalDecorator $timestampValidationLeeway; - protected JwsSerializerManager $jwsSerializerManager; - protected JwsParser $jwsParser; - protected JwsVerifier $jwsVerifier; + protected DateIntervalDecorator $timestampValidationLeewayDecorator; + protected ?JwsSerializerManager $jwsSerializerManager = null; + protected ?JwsParser $jwsParser = null; + protected ?JwsVerifier $jwsVerifier = null; protected ?RequestObjectFactory $requestObjectFactory = null; protected ?ClientAssertionFactory $clientAssertionFactory = null; + protected ?Helpers $helpers = null; + protected ?AlgorithmManagerFactory $algorithmManagerFactory = null; + protected ?JwsSerializerManagerFactory $jwsSerializerManagerFactory = null; + protected ?JwsParserFactory $jwsParserFactory = null; + protected ?JwsVerifierFactory $jwsVerifierFactory = null; + protected ?JwksFactory $jwksFactory = null; + protected ?DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = null; public function __construct( protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms( @@ -40,41 +47,109 @@ public function __construct( protected readonly SupportedSerializers $supportedSerializers = new SupportedSerializers(), DateInterval $timestampValidationLeeway = new DateInterval('PT1M'), protected readonly ?LoggerInterface $logger = null, - protected readonly Helpers $helpers = new Helpers(), - AlgorithmManagerFactory $algorithmManagerFactory = new AlgorithmManagerFactory(), - JwsSerializerManagerFactory $jwsSerializerManagerFactory = new JwsSerializerManagerFactory(), - JwsParserFactory $jwsParserFactory = new JwsParserFactory(), - JwsVerifierFactory $jwsVerifierFactory = new JwsVerifierFactory(), - protected JwksFactory $jwksFactory = new JwksFactory(), - DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = new DateIntervalDecoratorFactory(), ) { - $this->timestampValidationLeeway = $dateIntervalDecoratorFactory->build($timestampValidationLeeway); - $this->jwsSerializerManager = $jwsSerializerManagerFactory->build($this->supportedSerializers); - $this->jwsParser = $jwsParserFactory->build($this->jwsSerializerManager); - $this->jwsVerifier = $jwsVerifierFactory->build($algorithmManagerFactory->build($this->supportedAlgorithms)); + $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() + ->build($timestampValidationLeeway); } public function requestObjectFactory(): RequestObjectFactory { return $this->requestObjectFactory ??= new RequestObjectFactory( - $this->jwsParser, - $this->jwsVerifier, - $this->jwksFactory, - $this->jwsSerializerManager, - $this->timestampValidationLeeway, - $this->helpers, + $this->jwsParser(), + $this->jwsVerifier(), + $this->jwksFactory(), + $this->jwsSerializerManager(), + $this->timestampValidationLeewayDecorator, + $this->helpers(), ); } public function clientAssertionFactory(): ClientAssertionFactory { return $this->clientAssertionFactory ??= new ClientAssertionFactory( - $this->jwsParser, - $this->jwsVerifier, - $this->jwksFactory, - $this->jwsSerializerManager, - $this->timestampValidationLeeway, - $this->helpers, + $this->jwsParser(), + $this->jwsVerifier(), + $this->jwksFactory(), + $this->jwsSerializerManager(), + $this->timestampValidationLeewayDecorator, + $this->helpers(), ); } + + public function helpers(): Helpers + { + return $this->helpers ??= new Helpers(); + } + + public function algorithmManagerFactory(): AlgorithmManagerFactory + { + if (is_null($this->algorithmManagerFactory)) { + $this->algorithmManagerFactory = new AlgorithmManagerFactory(); + } + return $this->algorithmManagerFactory; + } + + public function jwsSerializerManagerFactory(): JwsSerializerManagerFactory + { + if (is_null($this->jwsSerializerManagerFactory)) { + $this->jwsSerializerManagerFactory = new JwsSerializerManagerFactory(); + } + return $this->jwsSerializerManagerFactory; + } + + public function jwsParserFactory(): JwsParserFactory + { + if (is_null($this->jwsParserFactory)) { + $this->jwsParserFactory = new JwsParserFactory(); + } + return $this->jwsParserFactory; + } + + public function jwsVerifierFactory(): JwsVerifierFactory + { + if (is_null($this->jwsVerifierFactory)) { + $this->jwsVerifierFactory = new JwsVerifierFactory(); + } + return $this->jwsVerifierFactory; + } + + public function jwksFactory(): JwksFactory + { + return $this->jwksFactory ??= new JwksFactory(); + } + + public function dateIntervalDecoratorFactory(): DateIntervalDecoratorFactory + { + if (is_null($this->dateIntervalDecoratorFactory)) { + $this->dateIntervalDecoratorFactory = new DateIntervalDecoratorFactory(); + } + + return $this->dateIntervalDecoratorFactory; + } + + public function jwsSerializerManager(): JwsSerializerManager + { + if (is_null($this->jwsSerializerManager)) { + $this->jwsSerializerManager = $this->jwsSerializerManagerFactory()->build($this->supportedSerializers); + } + return $this->jwsSerializerManager; + } + + public function jwsParser(): JwsParser + { + if (is_null($this->jwsParser)) { + $this->jwsParser = $this->jwsParserFactory()->build($this->jwsSerializerManager()); + } + return $this->jwsParser; + } + + public function jwsVerifier(): JwsVerifier + { + if (is_null($this->jwsVerifier)) { + $this->jwsVerifier = $this->jwsVerifierFactory()->build( + $this->algorithmManagerFactory()->build($this->supportedAlgorithms), + ); + } + return $this->jwsVerifier; + } } diff --git a/src/Core/ClientAssertion.php b/src/Core/ClientAssertion.php index a92142e..9a9db84 100644 --- a/src/Core/ClientAssertion.php +++ b/src/Core/ClientAssertion.php @@ -32,9 +32,11 @@ protected function validate(): void */ protected function validateIssuerAndSubject(): void { - ($this->getIssuer() === $this->getSubject()) || throw new ClientAssertionException( - 'Issuer claim is expected to be the same as Subject claim', - ); + if ($this->getIssuer() !== $this->getSubject()) { + throw new ClientAssertionException( + 'Issuer claim is expected to be the same as Subject claim', + ); + } } /** diff --git a/src/Decorators/HttpClientDecorator.php b/src/Decorators/HttpClientDecorator.php index e312584..046b1ba 100644 --- a/src/Decorators/HttpClientDecorator.php +++ b/src/Decorators/HttpClientDecorator.php @@ -14,11 +14,9 @@ class HttpClientDecorator { public const DEFAULT_HTTP_CLIENT_CONFIG = [RequestOptions::ALLOW_REDIRECTS => true,]; - public readonly Client $client; - public function __construct(?Client $client = null) + public function __construct(public readonly Client $client = new Client(self::DEFAULT_HTTP_CLIENT_CONFIG)) { - $this->client = $client ?? new Client(self::DEFAULT_HTTP_CLIENT_CONFIG); } /** @@ -37,7 +35,7 @@ public function request(HttpMethodsEnum $httpMethodsEnum, string $uri): Response throw new HttpException($message, (int)$e->getCode(), $e); } - if ($response->getStatusCode() !== 200) { + if ($response->getStatusCode() < 200 || $response->getStatusCode() > 299) { $message = sprintf( 'Unexpected HTTP response for URI %s. Status code: %s, reason: %s.', $uri, diff --git a/src/Factories/HttpClientDecoratorFactory.php b/src/Factories/HttpClientDecoratorFactory.php index 9eea140..b47e7c0 100644 --- a/src/Factories/HttpClientDecoratorFactory.php +++ b/src/Factories/HttpClientDecoratorFactory.php @@ -11,6 +11,6 @@ class HttpClientDecoratorFactory { public function build(?Client $client = null): HttpClientDecorator { - return new HttpClientDecorator($client); + return is_null($client) ? new HttpClientDecorator() : new HttpClientDecorator($client); } } diff --git a/src/Federation.php b/src/Federation.php index 24502c6..6791747 100644 --- a/src/Federation.php +++ b/src/Federation.php @@ -24,6 +24,7 @@ use SimpleSAML\OpenID\Federation\Factories\TrustChainBagFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; +use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\TrustChainResolver; use SimpleSAML\OpenID\Jwks\Factories\JwksFactory; @@ -32,6 +33,7 @@ use SimpleSAML\OpenID\Jws\JwsParser; use SimpleSAML\OpenID\Jws\JwsVerifier; use SimpleSAML\OpenID\Serializers\JwsSerializerManager; +use SimpleSAML\OpenID\Utils\ArtifactFetcher; class Federation { @@ -45,6 +47,7 @@ class Federation protected ?JwsVerifier $jwsVerifier = null; protected ?EntityStatementFetcher $entityStatementFetcher = null; protected ?MetadataPolicyResolver $metadataPolicyResolver = null; + protected ?MetadataPolicyApplicator $metadataPolicyApplicator = null; protected ?TrustChainFactory $trustChainFactory = null; protected ?TrustChainResolver $trustChainResolver = null; protected ?EntityStatementFactory $entityStatementFactory = null; @@ -62,6 +65,7 @@ class Federation protected ?HttpClientDecoratorFactory $httpClientDecoratorFactory = null; protected ?TrustChainBagFactory $trustChainBagFactory = null; protected ?CacheDecoratorFactory $cacheDecoratorFactory = null; + protected ?ArtifactFetcher $artifactFetcher = null; public function __construct( protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(), @@ -101,11 +105,10 @@ public function jwsSerializerManager(): JwsSerializerManager public function entityStatementFetcher(): EntityStatementFetcher { return $this->entityStatementFetcher ??= new EntityStatementFetcher( - $this->httpClientDecorator, $this->entityStatementFactory(), + $this->artifactFetcher(), $this->maxCacheDurationDecorator, $this->helpers(), - $this->cacheDecorator, $this->logger, ); } @@ -115,13 +118,18 @@ public function metadataPolicyResolver(): MetadataPolicyResolver return $this->metadataPolicyResolver ??= new MetadataPolicyResolver($this->helpers()); } + public function metadataPolicyApplicator(): MetadataPolicyApplicator + { + return $this->metadataPolicyApplicator ??= new MetadataPolicyApplicator($this->helpers()); + } + public function trustChainFactory(): TrustChainFactory { return $this->trustChainFactory ??= new TrustChainFactory( $this->entityStatementFactory(), $this->timestampValidationLeewayDecorator, - $this->helpers(), $this->metadataPolicyResolver(), + $this->metadataPolicyApplicator(), ); } @@ -258,9 +266,6 @@ public function supportedAlgorithms(): SupportedAlgorithms return $this->supportedAlgorithms; } - /** - * @return \SimpleSAML\OpenID\SupportedSerializers - */ public function supportedSerializers(): SupportedSerializers { return $this->supportedSerializers; @@ -275,4 +280,13 @@ public function cacheDecorator(): ?CacheDecorator { return $this->cacheDecorator; } + + public function artifactFetcher(): ArtifactFetcher + { + return $this->artifactFetcher ??= new ArtifactFetcher( + $this->httpClientDecorator, + $this->cacheDecorator(), + $this->logger, + ); + } } diff --git a/src/Federation/EntityStatement.php b/src/Federation/EntityStatement.php index b3d885c..c7ecabc 100644 --- a/src/Federation/EntityStatement.php +++ b/src/Federation/EntityStatement.php @@ -5,6 +5,7 @@ namespace SimpleSAML\OpenID\Federation; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; +use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; use SimpleSAML\OpenID\Codebooks\JwtTypesEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\EntityStatementException; @@ -80,7 +81,7 @@ public function getExpirationTime(): int /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException - * @return array[] + * @return array{keys:array>} * @psalm-suppress MixedReturnTypeCoercion */ public function getJwks(): array @@ -97,6 +98,12 @@ public function getJwks(): array throw new JwsException('Invalid JWKS encountered: ' . var_export($jwks, true)); } + $jwks[ClaimsEnum::Keys->value] = array_map( + $this->helpers->arr()->ensureStringKeys(...), + $jwks[ClaimsEnum::Keys->value], + ); + + /** @var array{keys:array>} $jwks */ return $jwks; } @@ -139,7 +146,7 @@ public function getAuthorityHints(): ?array } // Its value MUST contain the Entity Identifiers of its Immediate Superiors and MUST NOT be the empty array [] - if (empty($authorityHints)) { + if ($authorityHints === []) { throw new EntityStatementException('Empty Authority Hints claim encountered.'); } @@ -151,6 +158,58 @@ public function getAuthorityHints(): ?array return $this->ensureNonEmptyStrings($authorityHints, $claimKey); } + /** + * @return ?array + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + */ + public function getMetadata(): ?array + { + $claimKey = ClaimsEnum::Metadata->value; + /** @psalm-suppress MixedAssignment */ + $metadata = $this->getPayloadClaim($claimKey); + + if (is_null($metadata)) { + return null; + } + + // metadata + // OPTIONAL. JSON object that represents the Entity's Types and the metadata for those Entity Types. + if (!is_array($metadata)) { + throw new EntityStatementException('Invalid Metadata claim.'); + } + + return $this->helpers->arr()->ensureStringKeys($metadata); + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException + * @phpstan-ignore missingType.iterableValue (We will ensure proper format in policy resolver.) + */ + public function getMetadataPolicy(): ?array + { + $claimKey = ClaimsEnum::MetadataPolicy->value; + $metadataPolicy = $this->getPayloadClaim($claimKey); + + if (is_null($metadataPolicy)) { + return null; + } + + // metadata_policy + // OPTIONAL. JSON object that defines a metadata policy. + if (!is_array($metadataPolicy)) { + throw new EntityStatementException('Invalid Metadata Policy claim.'); + } + + // Only Subordinate Statements MAY include this claim. + if ($this->isConfiguration()) { + throw new EntityStatementException('Metadata Policy claim encountered in configuration statement.'); + } + + return $metadataPolicy; + } + /** * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -176,6 +235,7 @@ public function getTrustMarks(): ?TrustMarkClaimBag /** @psalm-suppress MixedAssignment */ while (is_array($trustMarkClaimData = array_pop($trustMarksClaims))) { + $trustMarkClaimData = $this->helpers->arr()->ensureStringKeys($trustMarkClaimData); $trustMarkClaimBag->add($this->trustMarkClaimFactory->buildFrom($trustMarkClaimData)); } @@ -192,6 +252,24 @@ public function getKeyId(): string return parent::getKeyId() ?? throw new EntityStatementException('No KeyId header claim found.'); } + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function getFederationFetchEndpoint(): ?string + { + /** @psalm-suppress MixedAssignment */ + $federationFetchEndpoint = $this->getPayload() + [ClaimsEnum::Metadata->value] + [EntityTypesEnum::FederationEntity->value] + [ClaimsEnum::FederationFetchEndpoint->value] ?? null; + + if (is_null($federationFetchEndpoint)) { + return null; + } + + return (string)$federationFetchEndpoint; + } + /** * @throws \SimpleSAML\OpenID\Exceptions\EntityStatementException * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -203,6 +281,7 @@ public function isConfiguration(): bool /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @phpstan-ignore missingType.iterableValue (Format is validated later.) */ public function verifyWithKeySet(?array $jwks = null, int $signatureIndex = 0): void { @@ -227,7 +306,10 @@ protected function validate(): void $this->getType(...), $this->getKeyId(...), $this->getAuthorityHints(...), + $this->getMetadata(...), + $this->getMetadataPolicy(...), $this->getTrustMarks(...), + $this->getFederationFetchEndpoint(...), ); } } diff --git a/src/Federation/EntityStatement/Factories/TrustMarkClaimFactory.php b/src/Federation/EntityStatement/Factories/TrustMarkClaimFactory.php index e7978bf..12a0079 100644 --- a/src/Federation/EntityStatement/Factories/TrustMarkClaimFactory.php +++ b/src/Federation/EntityStatement/Factories/TrustMarkClaimFactory.php @@ -18,6 +18,7 @@ public function __construct( } /** + * @param array $otherClaims * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkClaimException @@ -31,6 +32,7 @@ public function build( } /** + * @param array $trustMarkClaimData * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkClaimException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ diff --git a/src/Federation/EntityStatement/TrustMarkClaim.php b/src/Federation/EntityStatement/TrustMarkClaim.php index d793581..c41eddf 100644 --- a/src/Federation/EntityStatement/TrustMarkClaim.php +++ b/src/Federation/EntityStatement/TrustMarkClaim.php @@ -14,6 +14,7 @@ class TrustMarkClaim { /** + * @param array $otherClaims * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkClaimException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException * @throws \SimpleSAML\OpenID\Exceptions\JwsException @@ -58,6 +59,9 @@ public function getTrustMark(): TrustMark return $this->trustMark; } + /** + * @return array + */ public function getOtherClaims(): array { return $this->otherClaims; diff --git a/src/Federation/EntityStatementFetcher.php b/src/Federation/EntityStatementFetcher.php index a21115d..74460c2 100644 --- a/src/Federation/EntityStatementFetcher.php +++ b/src/Federation/EntityStatementFetcher.php @@ -7,29 +7,35 @@ use Psr\Log\LoggerInterface; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; -use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; -use SimpleSAML\OpenID\Codebooks\HttpHeadersEnum; -use SimpleSAML\OpenID\Codebooks\HttpMethodsEnum; use SimpleSAML\OpenID\Codebooks\WellKnownEnum; -use SimpleSAML\OpenID\Decorators\CacheDecorator; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; -use SimpleSAML\OpenID\Decorators\HttpClientDecorator; +use SimpleSAML\OpenID\Exceptions\EntityStatementException; use SimpleSAML\OpenID\Exceptions\FetchException; -use SimpleSAML\OpenID\Exceptions\JwsException; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Helpers; -use Throwable; +use SimpleSAML\OpenID\Jws\JwsFetcher; +use SimpleSAML\OpenID\Utils\ArtifactFetcher; -class EntityStatementFetcher +class EntityStatementFetcher extends JwsFetcher { public function __construct( - protected readonly HttpClientDecorator $httpClientDecorator, - protected readonly EntityStatementFactory $entityStatementFactory, - protected readonly DateIntervalDecorator $maxCacheDuration, - protected readonly Helpers $helpers, - protected readonly ?CacheDecorator $cacheDecorator = null, - protected readonly ?LoggerInterface $logger = null, + private readonly EntityStatementFactory $parsedJwsFactory, + ArtifactFetcher $artifactFetcher, + DateIntervalDecorator $maxCacheDuration, + Helpers $helpers, + ?LoggerInterface $logger = null, ) { + parent::__construct($parsedJwsFactory, $artifactFetcher, $maxCacheDuration, $helpers, $logger); + } + + protected function buildJwsInstance(string $token): EntityStatement + { + return $this->parsedJwsFactory->fromToken($token); + } + + public function getExpectedContentTypeHttpHeader(): string + { + return ContentTypesEnum::ApplicationEntityStatementJwt->value; } /** @@ -37,8 +43,6 @@ public function __construct( * Fetch will first check if the entity statement is available in cache. If not, it will do a network fetch. * * @param non-empty-string $entityId - * @param \SimpleSAML\OpenID\Codebooks\WellKnownEnum $wellKnownEnum - * @return \SimpleSAML\OpenID\Federation\EntityStatement * @throws \SimpleSAML\OpenID\Exceptions\FetchException * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ @@ -49,17 +53,15 @@ public function fromCacheOrWellKnownEndpoint( $wellKnownUri = $wellKnownEnum->uriFor($entityId); $this->logger?->debug( 'Entity statement fetch from cache or well-known endpoint.', - compact('entityId', 'wellKnownUri', 'wellKnownEnum'), + ['entityId' => $entityId, 'wellKnownUri' => $wellKnownUri, 'wellKnownEnum' => $wellKnownEnum], ); return $this->fromCacheOrNetwork($wellKnownUri); } /** - * @param string $subjectId * @param \SimpleSAML\OpenID\Federation\EntityStatement $entityConfiguration Entity from which to use the fetch * endpoint (issuer). - * @return \SimpleSAML\OpenID\Federation\EntityStatement * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\FetchException */ @@ -67,16 +69,12 @@ public function fromCacheOrFetchEndpoint( string $subjectId, EntityStatement $entityConfiguration, ): EntityStatement { - $entityConfigurationPayload = $entityConfiguration->getPayload(); - - $fetchEndpointUri = (string)($entityConfigurationPayload[ClaimsEnum::Metadata->value] - [EntityTypesEnum::FederationEntity->value] - [ClaimsEnum::FederationFetchEndpoint->value] ?? - throw new JwsException('No fetch endpoint found in entity configuration.')); + $fetchEndpointUri = $entityConfiguration->getFederationFetchEndpoint() ?? + throw new EntityStatementException('No fetch endpoint found in entity configuration.'); $this->logger?->debug( 'Entity statement fetch from cache or fetch endpoint.', - compact('subjectId', 'fetchEndpointUri'), + ['subjectId' => $subjectId, 'fetchEndpointUri' => $fetchEndpointUri], ); return $this->fromCacheOrNetwork( @@ -101,39 +99,30 @@ public function fromCacheOrNetwork(string $uri): EntityStatement /** * Fetch entity statement from cache, if available. URI is used as cache key. * - * @param string $uri - * @return \SimpleSAML\OpenID\Federation\EntityStatement|null * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\FetchException */ public function fromCache(string $uri): ?EntityStatement { - $this->logger?->debug( - 'Trying to get entity statement token from cache.', - compact('uri'), - ); + $entityStatement = parent::fromCache($uri); - try { - /** @var ?string $jws */ - $jws = $this->cacheDecorator?->get(null, $uri); - } catch (Throwable $exception) { - $this->logger?->error( - 'Error trying to get entity statement from cache: ' . $exception->getMessage(), - compact('uri'), - ); + if (is_null($entityStatement)) { return null; } - if (!is_string($jws)) { - $this->logger?->debug('Entity statement token not found in cache.', compact('uri')); - return null; + if ($entityStatement instanceof \SimpleSAML\OpenID\Federation\EntityStatement) { + return $entityStatement; } - $this->logger?->debug( - 'Entity statement token found in cache, trying to build instance.', - compact('uri'), + // @codeCoverageIgnoreStart + $message = 'Unexpected entity statement instance encountered for cache fetch.'; + $this->logger?->error( + $message, + ['uri' => $uri, 'entityStatement' => $entityStatement], ); - return $this->prepareEntityStatement($jws); + throw new FetchException($message); + // @codeCoverageIgnoreEnd } /** @@ -144,83 +133,20 @@ public function fromCache(string $uri): ?EntityStatement */ public function fromNetwork(string $uri): EntityStatement { - try { - $response = $this->httpClientDecorator->request(HttpMethodsEnum::GET, $uri); - } catch (Throwable $e) { - $message = sprintf( - 'Error sending HTTP request to %s. Error was: %s', - $uri, - $e->getMessage(), - ); - $this->logger?->error($message); - throw new FetchException($message, (int)$e->getCode(), $e); - } - - if ($response->getStatusCode() !== 200) { - $message = sprintf( - 'Unexpected HTTP response for entity statement fetch, status code: %s, reason: %s. URI %s', - $response->getStatusCode(), - $response->getReasonPhrase(), - $uri, - ); - $this->logger?->error($message); - throw new FetchException($message); - } + $entityStatement = parent::fromNetwork($uri); - /** @psalm-suppress InvalidLiteralArgument */ - if ( - !str_contains( - $response->getHeaderLine(HttpHeadersEnum::ContentType->value), - ContentTypesEnum::ApplicationEntityStatementJwt->value, - ) - ) { - $message = sprintf( - 'Unexpected content type in response for entity statement fetch: %s, expected: %s. URI %s', - $response->getHeaderLine(HttpHeadersEnum::ContentType->value), - ContentTypesEnum::ApplicationEntityStatementJwt->value, - $uri, - ); - $this->logger?->error($message); - throw new FetchException($message); + if ($entityStatement instanceof \SimpleSAML\OpenID\Federation\EntityStatement) { + return $entityStatement; } - $token = $response->getBody()->getContents(); - $this->logger?->debug('Successful HTTP response for entity statement fetch.', compact('uri', 'token')); - $this->logger?->debug('Proceeding to EntityStatement instance building.'); - - $entityStatement = $this->entityStatementFactory->fromToken($token); - $this->logger?->debug('Entity Statement instance built, saving its token to cache.', compact('uri', 'token')); - - // Cache it - try { - $cacheTtl = $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime( - $entityStatement->getExpirationTime(), - ); - $this->cacheDecorator?->set( - $token, - $cacheTtl, - $uri, - ); - $this->logger?->debug( - 'Entity Statement token successfully cached.', - compact('uri', 'token', 'cacheTtl'), - ); - } catch (Throwable $exception) { - $this->logger?->error( - 'Error setting entity statement to cache: ' . $exception->getMessage(), - compact('uri'), - ); - } - - $this->logger?->debug('Returning built Entity Statement instance.', compact('uri', 'token')); - return $entityStatement; - } + // @codeCoverageIgnoreStart + $message = 'Unexpected entity statement instance encountered for network fetch.'; + $this->logger?->error( + $message, + ['uri' => $uri, 'entityStatement' => $entityStatement], + ); - /** - * @throws \SimpleSAML\OpenID\Exceptions\JwsException - */ - protected function prepareEntityStatement(string $jws): EntityStatement - { - return $this->entityStatementFactory->fromToken($jws); + throw new FetchException($message); + // @codeCoverageIgnoreEnd } } diff --git a/src/Federation/Factories/TrustChainFactory.php b/src/Federation/Factories/TrustChainFactory.php index 2efad8c..f360f02 100644 --- a/src/Federation/Factories/TrustChainFactory.php +++ b/src/Federation/Factories/TrustChainFactory.php @@ -6,17 +6,17 @@ use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Federation\EntityStatement; +use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\TrustChain; -use SimpleSAML\OpenID\Helpers; class TrustChainFactory { public function __construct( protected readonly EntityStatementFactory $entityStatementFactory, protected readonly DateIntervalDecorator $timestampValidationLeeway, - protected readonly Helpers $helpers, protected readonly MetadataPolicyResolver $metadataPolicyResolver, + protected readonly MetadataPolicyApplicator $metadataPolicyApplicator, ) { } @@ -24,8 +24,8 @@ public function empty(): TrustChain { return new TrustChain( $this->timestampValidationLeeway, - $this->helpers, $this->metadataPolicyResolver, + $this->metadataPolicyApplicator, ); } diff --git a/src/Federation/MetadataPolicyApplicator.php b/src/Federation/MetadataPolicyApplicator.php new file mode 100644 index 0000000..19c4f5a --- /dev/null +++ b/src/Federation/MetadataPolicyApplicator.php @@ -0,0 +1,221 @@ +> $resolvedMetadataPolicy Resolved (validated) metadata policy. + * @param array $metadata + * @return array Metadata with applied policies. + * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException + * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException + */ + public function for( + array $resolvedMetadataPolicy, + array $metadata, + ): array { + foreach ($resolvedMetadataPolicy as $policyParameterName => $policyOperations) { + foreach (MetadataPolicyOperatorsEnum::cases() as $metadataPolicyOperatorEnum) { + if (!array_key_exists($metadataPolicyOperatorEnum->value, $policyOperations)) { + continue; + } + /** @psalm-suppress MixedAssignment */ + $operatorValue = $policyOperations[$metadataPolicyOperatorEnum->value]; + /** @psalm-suppress MixedAssignment, MixedArgumentTypeCoercion */ + $metadataParameterValueBeforePolicy = $this->resolveParameterValueBeforePolicy( + $metadata, + $policyParameterName, + ); + + if ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Value) { + // The metadata parameter MUST be assigned the value of the operator. When the value of the operator + // is null, the metadata parameter MUST be removed. + if (is_null($operatorValue)) { + unset($metadata[$policyParameterName]); + continue; + } + $this->helpers->arr()->ensureArrayDepth($metadata, $policyParameterName); + /** @psalm-suppress MixedAssignment */ + $metadata[$policyParameterName] = $this->resolveParameterValueAfterPolicy( + $operatorValue, + $policyParameterName, + ); + } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Add) { + // The value or values of this operator MUST be added to the metadata parameter. Values that are + // already present in the metadata parameter MUST NOT be added another time. If the metadata + // parameter is absent, it MUST be initialized with the value of this operator. + if (!isset($metadata[$policyParameterName])) { + /** @psalm-suppress MixedAssignment */ + $metadata[$policyParameterName] = $operatorValue; + continue; + } + + $metadataPolicyOperatorEnum->validateMetadataParameterValueType( + $metadataParameterValueBeforePolicy, + $policyParameterName, + ); + + /** @psalm-suppress MixedArgument */ + $metadataParameterValue = array_unique( + array_merge($metadataParameterValueBeforePolicy, $operatorValue), + ); + + /** @psalm-suppress MixedAssignment */ + $metadata[$policyParameterName] = $this->resolveParameterValueAfterPolicy( + $metadataParameterValue, + $policyParameterName, + ); + } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Default) { + // If the metadata parameter is absent, it MUST be set to the value of the operator. If the metadata + // parameter is present, this operator has no effect. + if (!isset($metadata[$policyParameterName])) { + /** @psalm-suppress MixedAssignment */ + $metadata[$policyParameterName] = $operatorValue; + } + } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::OneOf) { + // If the metadata parameter is present, its value MUST be one of those listed in the operator + // value. + if (!isset($metadata[$policyParameterName])) { + continue; + } + + $metadataPolicyOperatorEnum->validateMetadataParameterValueType( + $metadataParameterValueBeforePolicy, + $policyParameterName, + ); + + /** @var array $operatorValue Set bc of phpstan */ + if (!in_array($metadataParameterValueBeforePolicy, $operatorValue, true)) { + throw new MetadataPolicyException( + sprintf( + 'Metadata parameter %s, value %s is not one of %s.', + $policyParameterName, + var_export($metadataParameterValueBeforePolicy, true), + var_export($operatorValue, true), + ), + ); + } + } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::SubsetOf) { + // If the metadata parameter is present, this operator computes the intersection between the values + // of the operator and the metadata parameter. If the intersection is non-empty, the metadata + // parameter is set to the values in the intersection. If the intersection is empty, the + // metadata parameter MUST be removed. Note that this behavior makes subset_of a + // potential value modifier in addition to it being a value check. + if (!isset($metadata[$policyParameterName])) { + continue; + } + + $metadataPolicyOperatorEnum->validateMetadataParameterValueType( + $metadataParameterValueBeforePolicy, + $policyParameterName, + ); + + /** @psalm-suppress MixedArgument */ + $intersection = array_intersect( + $metadataParameterValueBeforePolicy, + $operatorValue, + ); + + if ($intersection === []) { + unset($metadata[$policyParameterName]); + continue; + } + /** @psalm-suppress MixedAssignment */ + $metadata[$policyParameterName] = $this->resolveParameterValueAfterPolicy( + $intersection, + $policyParameterName, + ); + } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::SupersetOf) { + // If the metadata parameter is present, its values MUST contain those specified in the operator + // value. By mathematically defining supersets, equality is included. + if (!isset($metadata[$policyParameterName])) { + continue; + } + + $metadataPolicyOperatorEnum->validateMetadataParameterValueType( + $metadataParameterValueBeforePolicy, + $policyParameterName, + ); + + /** @var array $operatorValue Set bc of phpstan */ + if ( + !$metadataPolicyOperatorEnum->isValueSupersetOf( + $metadataParameterValueBeforePolicy, + $operatorValue, + ) + ) { + throw new MetadataPolicyException( + sprintf( + 'Parameter %s, operator %s, value %s is not superset of %s.', + $policyParameterName, + $metadataPolicyOperatorEnum->value, + var_export($metadataParameterValueBeforePolicy, true), + var_export($operatorValue, true), + ), + ); + } + } else { + // This is operator 'essential' + // If the value of this operator is true, then the metadata parameter MUST be present. If false, + // the metadata parameter is voluntary and may be absent. If the essential operator is omitted, + // this is equivalent to including it with a value of false. + if (!$operatorValue) { + continue; + } + + if (!isset($metadata[$policyParameterName])) { + throw new MetadataPolicyException( + sprintf( + 'Parameter %s is marked as essential by policy, but not present in metadata.', + $policyParameterName, + ), + ); + } + } + } + } + + /** @var array $metadata */ + return $metadata; + } + + /** + * @param array $metadata + */ + protected function resolveParameterValueBeforePolicy(array $metadata, string $parameter): mixed + { + /** @psalm-suppress MixedAssignment */ + $value = $metadata[$parameter] ?? null; + + // Special case for 'scope' parameter, which needs to be converted to array before policy application. + if (($parameter === ClaimsEnum::Scope->value) && is_string($value)) { + $value = explode(' ', $value); + } + + return $value; + } + + protected function resolveParameterValueAfterPolicy(mixed $value, string $parameter): mixed + { + // Special case for 'scope' parameter, which needs to be converted to string after policy application. + if (($parameter === ClaimsEnum::Scope->value) && is_array($value)) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + $value = implode(' ', $value); + } + + return $value; + } +} diff --git a/src/Federation/MetadataPolicyResolver.php b/src/Federation/MetadataPolicyResolver.php index a116220..6a6f72c 100644 --- a/src/Federation/MetadataPolicyResolver.php +++ b/src/Federation/MetadataPolicyResolver.php @@ -17,9 +17,46 @@ public function __construct( } /** - * @param array[] $metadataPolicies + * @return array>> + * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException + * @psalm-suppress MixedAssignment + * @phpstan-ignore missingType.iterableValue (We validate it here) + */ + public function ensureFormat(array $metadataPolicies): array + { + foreach ($metadataPolicies as $entityType => $metadataPolicyEntityType) { + if (!is_string($entityType)) { + throw new MetadataPolicyException('Invalid metadata policy format (entity type key).'); + } + if (!is_array($metadataPolicyEntityType)) { + throw new MetadataPolicyException('Invalid metadata policy format (entity type value).'); + } + + foreach ($metadataPolicyEntityType as $parameter => $metadataPolicyParameter) { + if (!is_string($parameter)) { + throw new MetadataPolicyException('Invalid metadata policy format (parameter key).'); + } + if (!is_array($metadataPolicyParameter)) { + throw new MetadataPolicyException('Invalid metadata policy format (parameter value).'); + } + + $operators = array_keys($metadataPolicyParameter); + foreach ($operators as $operator) { + if (!is_string($operator)) { + throw new MetadataPolicyException('Invalid metadata policy format (operator key).'); + } + } + } + } + + /** @var array>> $metadataPolicies */ + return $metadataPolicies; + } + + /** + * @param array>>> $metadataPolicies * @param string[] $criticalMetadataPolicyOperators - * + * @return array> * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException */ @@ -28,6 +65,7 @@ public function for( array $metadataPolicies, array $criticalMetadataPolicyOperators = [], ): array { + /** @var array> $currentPolicy */ $currentPolicy = []; $supportedOperators = MetadataPolicyOperatorsEnum::values(); @@ -35,6 +73,7 @@ public function for( /** @psalm-suppress MixedAssignment We'll check if $nextPolicy is array type. */ if ( (!array_key_exists($entityTypeEnum->value, $metadataPolicy)) || + /** @phpstan-ignore booleanNot.alwaysFalse (Let's check for validity here.) */ (!is_array($nextPolicy = $metadataPolicy[$entityTypeEnum->value])) ) { continue; @@ -48,25 +87,21 @@ public function for( ); // Disregard unsupported if not critical, otherwise throw. - (empty($unsupportedCriticalOperators = array_intersect( - $criticalMetadataPolicyOperators, - array_diff($allNextPolicyOperators, $supportedOperators), // Unsupported operators, but can be ignored - ))) - || throw new MetadataPolicyException( - 'Unsupported critical metadata policy operator(s) encountered: ' . - implode(', ', $unsupportedCriticalOperators), - ); + if ( + ($unsupportedCriticalOperators = array_intersect( + $criticalMetadataPolicyOperators, + array_diff($allNextPolicyOperators, $supportedOperators), // Unsupported operators, but ignored + )) !== [] + ) { + throw new MetadataPolicyException( + 'Unsupported critical metadata policy operator(s) encountered: ' . + implode(', ', $unsupportedCriticalOperators), + ); + } // Go over each metadata parameter and resolve the policy. /** @psalm-suppress MixedAssignment We'll check if $nextPolicyParameterOperations is array type. */ foreach ($nextPolicy as $nextPolicyParameter => $nextPolicyParameterOperations) { - (is_array($nextPolicyParameterOperations)) || throw new MetadataPolicyException( - sprintf( - 'Invalid format for metadata policy operations encountered: %s', - var_export($nextPolicyParameterOperations, true), - ), - ); - MetadataPolicyOperatorsEnum::validateGeneralParameterOperationRules($nextPolicyParameterOperations); MetadataPolicyOperatorsEnum::validateSpecificParameterOperationRules($nextPolicyParameterOperations); @@ -146,50 +181,50 @@ public function for( ); /** @psalm-suppress MixedArrayAccess, MixedArrayAssignment We ensured this is array. */ - (!empty($intersection)) || throw new MetadataPolicyException( - sprintf( - 'Empty intersection encountered for operator %s: %s | %s.', - $metadataPolicyOperatorEnum->value, - var_export( - $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value], - true, + if ($intersection === []) { + throw new MetadataPolicyException( + sprintf( + 'Empty intersection encountered for operator %s: %s | %s.', + $metadataPolicyOperatorEnum->value, + var_export( + $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value], + true, + ), + var_export($operatorValue, true), ), - var_export($operatorValue, true), - ), - ); + ); + } // We have values in intersection, so set it as new operator value. /** @psalm-suppress MixedArrayAccess, MixedArrayAssignment We ensured this is array. */ $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value] = $intersection; - } else { + } elseif ($currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value] === false) { // This is operator essential. // If a Superior has specified essential=true, then a Subordinate MUST NOT change that. // If a Superior has specified essential=false, then a Subordinate is allowed to change // that to essential=true. /** @psalm-suppress MixedArrayAccess, MixedArrayAssignment We ensured this is array. */ - if ($currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value] === false) { - $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value] = - (bool)$operatorValue; - } elseif ($operatorValue !== true) { - throw new MetadataPolicyException( - /** @psalm-suppress MixedArrayAccess We ensured this is array. */ - sprintf( - 'Invalid change of value for operator %s: %s -> %s.', - $metadataPolicyOperatorEnum->value, - var_export( - $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value], - true, - ), - var_export($operatorValue, true), + $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value] = + (bool)$operatorValue; + } elseif ($operatorValue !== true) { + /** @psalm-suppress MixedArrayAccess We ensured this is array. */ + throw new MetadataPolicyException( + sprintf( + 'Invalid change of value for operator %s: %s -> %s.', + $metadataPolicyOperatorEnum->value, + var_export( + $currentPolicy[$nextPolicyParameter][$metadataPolicyOperatorEnum->value], + true, ), - ); - } + var_export($operatorValue, true), + ), + ); } } // Check if the current policy is in valid state after merge. - /** @var array $currentPolicyParameterOperations We ensured this is array. */ + /** @var array> $currentPolicy */ foreach ($currentPolicy as $currentPolicyParameterOperations) { MetadataPolicyOperatorsEnum::validateGeneralParameterOperationRules( $currentPolicyParameterOperations, diff --git a/src/Federation/RequestObject.php b/src/Federation/RequestObject.php index 5373571..078b5e1 100644 --- a/src/Federation/RequestObject.php +++ b/src/Federation/RequestObject.php @@ -66,20 +66,22 @@ public function getExpirationTime(): int } /** + * @return ?string[] * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\RequestObjectException */ public function getTrustChain(): ?array { + $claimKey = ClaimsEnum::TrustChain->value; /** @psalm-suppress MixedAssignment */ - $trustChain = $this->getPayloadClaim(ClaimsEnum::TrustChain->value) ?? null; + $trustChain = $this->getPayloadClaim($claimKey) ?? null; if (is_null($trustChain)) { return null; } if (is_array($trustChain)) { - return $trustChain; + return $this->ensureNonEmptyStrings($trustChain, $claimKey); } throw new RequestObjectException( diff --git a/src/Federation/TrustChain.php b/src/Federation/TrustChain.php index bcb6b55..7f914b1 100644 --- a/src/Federation/TrustChain.php +++ b/src/Federation/TrustChain.php @@ -7,12 +7,9 @@ use JsonSerializable; use SimpleSAML\OpenID\Codebooks\ClaimsEnum; use SimpleSAML\OpenID\Codebooks\EntityTypesEnum; -use SimpleSAML\OpenID\Codebooks\MetadataPolicyOperatorsEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Exceptions\EntityStatementException; -use SimpleSAML\OpenID\Exceptions\MetadataPolicyException; use SimpleSAML\OpenID\Exceptions\TrustChainException; -use SimpleSAML\OpenID\Helpers; class TrustChain implements JsonSerializable { @@ -39,39 +36,37 @@ class TrustChain implements JsonSerializable * issued by the most Superior Entity and ends with the Subordinate Statement issued by the Immediate Superior * of the Trust Chain subject. * - * @var array[] + * @var array>>> */ protected array $metadataPolicies = []; /** * Resolved metadata policy per entity type. * - * @var array[] + * @var array>> */ protected array $resolvedMetadataPolicy = []; /** * Resolved metadata (after applying resolved policy) per entity type. * - * @var array + * @var array> */ protected array $resolvedMetadata = []; public function __construct( - protected readonly DateIntervalDecorator $timestampValidationLeeway, - protected readonly Helpers $helpers, + protected readonly DateIntervalDecorator $timestampValidationLeewayDecorator, protected readonly MetadataPolicyResolver $metadataPolicyResolver, + protected readonly MetadataPolicyApplicator $metadataPolicyApplicator, ) { } /** * Check if the trust chain is (currently) empty, meaning there are no entity statements present in the chain. - * - * @return bool */ public function isEmpty(): bool { - return empty($this->entities); + return $this->entities === []; } /** @@ -127,6 +122,7 @@ public function getResolvedTrustAnchor(): EntityStatement } /** + * @return ?array * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\OpenIdException @@ -148,7 +144,6 @@ public function getResolvedMetadata(EntityTypesEnum $entityTypeEnum): ?array /** * Get resolved chain length. * - * @return int * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException */ public function getResolvedLength(): int @@ -238,10 +233,11 @@ public function validateExpirationTime(): void return; } - ($this->expirationTime + $this->timestampValidationLeeway->getInSeconds() >= time()) || - throw new TrustChainException( - "Trust Chain expiration time ($this->expirationTime) is lesser than current time.", - ); + if ($this->expirationTime + $this->timestampValidationLeewayDecorator->getInSeconds() < time()) { + throw new TrustChainException( + "Trust Chain expiration time ($this->expirationTime) is lesser than current time.", + ); + } } /** @@ -269,8 +265,9 @@ protected function validateIsNotResolved(): void */ protected function validateIsEmpty(): void { - empty($this->entities) || - throw new TrustChainException('Trust Chain is expected to be empty at this point.'); + if ($this->entities !== []) { + throw new TrustChainException('Trust Chain is expected to be empty at this point.'); + } } /** @@ -278,8 +275,9 @@ protected function validateIsEmpty(): void */ protected function validateIsNotEmpty(): void { - !empty($this->entities) || - throw new TrustChainException('Trust Chain is expected to be non-empty at this point.'); + if ($this->entities === []) { + throw new TrustChainException('Trust Chain is expected to be non-empty at this point.'); + } } /** @@ -287,8 +285,9 @@ protected function validateIsNotEmpty(): void */ protected function validateAtLeastNumberOfEntities(int $count): void { - (count($this->entities) >= $count) || - throw new TrustChainException("Trust Chain is expected to have at least $count entity/ies at this point."); + if (count($this->entities) < $count) { + throw new TrustChainException("Trust Chain is expected to have at least $count entity/ies at this point."); + } } /** @@ -361,12 +360,15 @@ protected function gatherCriticalMetadataPolicyOperators(EntityStatement $entity /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\MetadataPolicyException */ protected function gatherMetadataPolicies(EntityStatement $entityStatement): void { - $policy = (array)($entityStatement->getPayloadClaim(ClaimsEnum::MetadataPolicy->value) ?? []); + $policy = $this->metadataPolicyResolver->ensureFormat( + $entityStatement->getMetadataPolicy() ?? [], + ); - if (!empty($policy)) { + if ($policy !== []) { array_unshift($this->metadataPolicies, $policy); } } @@ -392,7 +394,7 @@ protected function resolveMetadataFor(EntityTypesEnum $entityTypeEnum): void // Configuration MUST contain a metadata claim with JSON object values for each of the corresponding // Entity Type Identifiers, even if the values are the empty JSON object {} (when the Entity Type // has no associated metadata or Immediate Superiors supply any needed metadata). - $leafMetadata = $this->getResolvedLeaf()->getPayloadClaim(ClaimsEnum::Metadata->value); + $leafMetadata = $this->getResolvedLeaf()->getMetadata(); if ( (!is_array($leafMetadata)) || // Claim 'metadata' is optional. (!isset($leafMetadata[$entityTypeEnum->value])) || // If no metadata for given entity type @@ -414,8 +416,7 @@ protected function resolveMetadataFor(EntityTypesEnum $entityTypeEnum): void // appear in a Subordinate Statement, then the stated metadata MUST // be applied before the metadata_policy. /** @psalm-suppress MixedAssignment We check type manually. */ - $immediateSuperiorMetadata = $this->getResolvedImmediateSuperior() - ->getPayloadClaim(ClaimsEnum::Metadata->value); + $immediateSuperiorMetadata = $this->getResolvedImmediateSuperior()->getMetadata(); if ( is_array($immediateSuperiorMetadata) && isset($immediateSuperiorMetadata[$entityTypeEnum->value]) && @@ -433,196 +434,22 @@ protected function resolveMetadataFor(EntityTypesEnum $entityTypeEnum): void // to it. /** @psalm-suppress RiskyTruthyFalsyComparison */ if (empty($this->resolvedMetadataPolicy[$entityTypeEnum->value])) { + /** @var array $leafMetadataEntityType */ $this->resolvedMetadata[$entityTypeEnum->value] = $leafMetadataEntityType; return; } // Policy application to leaf metadata. - /** - * @var string $policyParameterName - * @var array $policyOperations - */ - foreach ($this->resolvedMetadataPolicy[$entityTypeEnum->value] as $policyParameterName => $policyOperations) { - foreach (MetadataPolicyOperatorsEnum::cases() as $metadataPolicyOperatorEnum) { - if (!array_key_exists($metadataPolicyOperatorEnum->value, $policyOperations)) { - continue; - } - /** @psalm-suppress MixedAssignment */ - $operatorValue = $policyOperations[$metadataPolicyOperatorEnum->value]; - /** @psalm-suppress MixedAssignment */ - $metadataParameterValueBeforePolicy = $this->resolveParameterValueBeforePolicy( - $leafMetadataEntityType, - $policyParameterName, - ); - - if ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Value) { - // The metadata parameter MUST be assigned the value of the operator. When the value of the operator - // is null, the metadata parameter MUST be removed. - if (is_null($operatorValue)) { - unset($leafMetadataEntityType[$policyParameterName]); - continue; - } - $this->helpers->arr()->ensureArrayDepth($leafMetadataEntityType, $policyParameterName); - /** @psalm-suppress MixedAssignment */ - $leafMetadataEntityType[$policyParameterName] = $this->resolveParameterValueAfterPolicy( - $operatorValue, - $policyParameterName, - ); - } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Add) { - // The value or values of this operator MUST be added to the metadata parameter. Values that are - // already present in the metadata parameter MUST NOT be added another time. If the metadata - // parameter is absent, it MUST be initialized with the value of this operator. - if (!isset($leafMetadataEntityType[$policyParameterName])) { - /** @psalm-suppress MixedAssignment */ - $leafMetadataEntityType[$policyParameterName] = $operatorValue; - continue; - } - - $metadataPolicyOperatorEnum->validateMetadataParameterValueType( - $metadataParameterValueBeforePolicy, - $policyParameterName, - ); - - /** @psalm-suppress MixedArgument */ - $metadataParameterValue = array_unique( - array_merge($metadataParameterValueBeforePolicy, $operatorValue), - ); - - /** @psalm-suppress MixedAssignment */ - $leafMetadataEntityType[$policyParameterName] = $this->resolveParameterValueAfterPolicy( - $metadataParameterValue, - $policyParameterName, - ); - } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::Default) { - // If the metadata parameter is absent, it MUST be set to the value of the operator. If the metadata - // parameter is present, this operator has no effect. - if (!isset($leafMetadataEntityType[$policyParameterName])) { - /** @psalm-suppress MixedAssignment */ - $leafMetadataEntityType[$policyParameterName] = $operatorValue; - } - } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::OneOf) { - // If the metadata parameter is present, its value MUST be one of those listed in the operator - // value. - if (!isset($leafMetadataEntityType[$policyParameterName])) { - continue; - } - - $metadataPolicyOperatorEnum->validateMetadataParameterValueType( - $metadataParameterValueBeforePolicy, - $policyParameterName, - ); - - /** @var array $operatorValue */ - (in_array($metadataParameterValueBeforePolicy, $operatorValue, true)) || - throw new MetadataPolicyException( - sprintf( - 'Metadata parameter %s, value %s is not one of %s.', - $policyParameterName, - var_export($metadataParameterValueBeforePolicy, true), - var_export($operatorValue, true), - ), - ); - } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::SubsetOf) { - // If the metadata parameter is present, this operator computes the intersection between the values - // of the operator and the metadata parameter. If the intersection is non-empty, the metadata - // parameter is set to the values in the intersection. If the intersection is empty, the - // metadata parameter MUST be removed. Note that this behavior makes subset_of a - // potential value modifier in addition to it being a value check. - if (!isset($leafMetadataEntityType[$policyParameterName])) { - continue; - } - - $metadataPolicyOperatorEnum->validateMetadataParameterValueType( - $metadataParameterValueBeforePolicy, - $policyParameterName, - ); - - /** @psalm-suppress MixedArgument */ - $intersection = array_intersect( - $metadataParameterValueBeforePolicy, - $operatorValue, - ); - - if (empty($intersection)) { - unset($leafMetadataEntityType[$policyParameterName]); - continue; - } - /** @psalm-suppress MixedAssignment */ - $leafMetadataEntityType[$policyParameterName] = $this->resolveParameterValueAfterPolicy( - $intersection, - $policyParameterName, - ); - } elseif ($metadataPolicyOperatorEnum === MetadataPolicyOperatorsEnum::SupersetOf) { - // If the metadata parameter is present, its values MUST contain those specified in the operator - // value. By mathematically defining supersets, equality is included. - if (!isset($leafMetadataEntityType[$policyParameterName])) { - continue; - } - - $metadataPolicyOperatorEnum->validateMetadataParameterValueType( - $metadataParameterValueBeforePolicy, - $policyParameterName, - ); - - /** @var array $operatorValue */ - ($metadataPolicyOperatorEnum->isValueSupersetOf( - $metadataParameterValueBeforePolicy, - $operatorValue, - )) || throw new MetadataPolicyException( - sprintf( - 'Parameter %s, operator %s, value %s is not superset of %s.', - $policyParameterName, - $metadataPolicyOperatorEnum->value, - var_export($metadataParameterValueBeforePolicy, true), - var_export($operatorValue, true), - ), - ); - } else { - // This is operator 'essential' - // If the value of this operator is true, then the metadata parameter MUST be present. If false, - // the metadata parameter is voluntary and may be absent. If the essential operator is omitted, - // this is equivalent to including it with a value of false. - if (!$operatorValue) { - continue; - } - - isset($leafMetadataEntityType[$policyParameterName]) || throw new MetadataPolicyException( - sprintf( - 'Parameter %s is marked as essential by policy, but not present in metadata.', - $policyParameterName, - ), - ); - } - } - } - - $this->resolvedMetadata[$entityTypeEnum->value] = $leafMetadataEntityType; - } - - protected function resolveParameterValueBeforePolicy(array $metadata, string $parameter): mixed - { - /** @psalm-suppress MixedAssignment */ - $value = $metadata[$parameter] ?? null; - - // Special case for 'scope' parameter, which needs to be converted to array before policy application. - if (($parameter === ClaimsEnum::Scope->value) && is_string($value)) { - $value = explode(' ', $value); - } - - return $value; - } - - protected function resolveParameterValueAfterPolicy(mixed $value, string $parameter): mixed - { - // Special case for 'scope' parameter, which needs to be converted to string after policy application. - if (($parameter === ClaimsEnum::Scope->value) && is_array($value)) { - /** @psalm-suppress MixedArgumentTypeCoercion */ - $value = implode(' ', $value); - } - - return $value; + /** @var array $leafMetadataEntityType */ + $this->resolvedMetadata[$entityTypeEnum->value] = $this->metadataPolicyApplicator->for( + $this->resolvedMetadataPolicy[$entityTypeEnum->value], + $leafMetadataEntityType, + ); } + /** + * @return \SimpleSAML\OpenID\Federation\EntityStatement[] + */ public function getEntities(): array { return $this->entities; diff --git a/src/Federation/TrustChainBag.php b/src/Federation/TrustChainBag.php index b823d04..4d95903 100644 --- a/src/Federation/TrustChainBag.php +++ b/src/Federation/TrustChainBag.php @@ -22,9 +22,10 @@ public function add(TrustChain $trustChain, TrustChain ...$trustChains): void $this->trustChains = array_merge($this->trustChains, $trustChains); // Order the chains from shortest to longest one. - usort($this->trustChains, function (TrustChain $a, TrustChain $b) { - return $a->getResolvedLength() <=> $b->getResolvedLength(); - }); + usort( + $this->trustChains, + fn(TrustChain $a, TrustChain $b): int => $a->getResolvedLength() <=> $b->getResolvedLength(), + ); } /** @@ -51,7 +52,7 @@ public function getShortestByTrustAnchorPriority(string $trustAnchorId, string . $prioritizedChains = $this->trustChains; - usort($prioritizedChains, function (TrustChain $a, TrustChain $b) use ($prioritizedTrustAnchorIds) { + usort($prioritizedChains, function (TrustChain $a, TrustChain $b) use ($prioritizedTrustAnchorIds): int { // Get defined position, or default to high value if not found. $posA = $prioritizedTrustAnchorIds[$a->getResolvedTrustAnchor()->getIssuer()] ?? PHP_INT_MAX; $posB = $prioritizedTrustAnchorIds[$b->getResolvedTrustAnchor()->getIssuer()] ?? PHP_INT_MAX; diff --git a/src/Federation/TrustChainResolver.php b/src/Federation/TrustChainResolver.php index d722e5d..828beff 100644 --- a/src/Federation/TrustChainResolver.php +++ b/src/Federation/TrustChainResolver.php @@ -48,7 +48,12 @@ public function getConfigurationChains( int $depth = 1, ): array { $populatedChainEntityIds = array_keys($populatedChain); - $debugStartInfo = compact('depth', 'entityId', 'trustAnchorIds', 'populatedChainEntityIds'); + $debugStartInfo = [ + 'depth' => $depth, + 'entityId' => $entityId, + 'trustAnchorIds' => $trustAnchorIds, + 'populatedChainEntityIds' => $populatedChainEntityIds, + ]; $this->logger?->debug('Start getting configuration chains.', $debugStartInfo); $configurationChains = []; @@ -114,7 +119,7 @@ public function getConfigurationChains( try { $entityAuthorityHints = $entityConfig->getAuthorityHints(); - if ((!is_array($entityAuthorityHints)) || empty($entityAuthorityHints)) { + if ((!is_array($entityAuthorityHints)) || $entityAuthorityHints === []) { $this->logger?->info('No common trust anchor in this path.', $debugStartInfo); return $configurationChains; } @@ -161,20 +166,19 @@ public function getConfigurationChains( * * @param non-empty-string $entityId ID of the leaf (subject) entity for which to resolve the trust chain. * @param non-empty-array $validTrustAnchorIds IDs of the valid trust anchors. - * @return \SimpleSAML\OpenID\Federation\TrustChainBag * * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException */ public function for(string $entityId, array $validTrustAnchorIds): TrustChainBag { $this->validateStart($entityId, $validTrustAnchorIds); - $debugStartInfo = compact('entityId', 'validTrustAnchorIds'); + $debugStartInfo = ['entityId' => $entityId, 'validTrustAnchorIds' => $validTrustAnchorIds]; $this->logger?->debug('Trust chain resolving started.', $debugStartInfo); $resolvedChains = []; foreach ($validTrustAnchorIds as $index => $validTrustAnchorId) { - $debugCacheQueryInfo = compact('entityId', 'validTrustAnchorId'); + $debugCacheQueryInfo = ['entityId' => $entityId, 'validTrustAnchorId' => $validTrustAnchorId]; $this->logger?->debug('Checking if the trust chain exists in cache.', $debugCacheQueryInfo); try { /** @var ?string[] $tokens */ @@ -198,11 +202,11 @@ public function for(string $entityId, array $validTrustAnchorIds): TrustChainBag } } - if (!empty($validTrustAnchorIds)) { - $debugStandardResolveInfo = compact('entityId', 'validTrustAnchorIds'); + if ($validTrustAnchorIds !== []) { + $debugStandardResolveInfo = ['entityId' => $entityId, 'validTrustAnchorIds' => $validTrustAnchorIds]; $this->logger?->debug( 'Continuing with standard resolving for remaining valid trust anchor IDs.', - compact('entityId', 'validTrustAnchorIds'), + ['entityId' => $entityId, 'validTrustAnchorIds' => $validTrustAnchorIds], ); $this->logger?->debug('Start fetching all configuration chains.', $debugStandardResolveInfo); @@ -257,7 +261,7 @@ public function for(string $entityId, array $validTrustAnchorIds): TrustChainBag } } - if (empty($resolvedChains)) { + if ($resolvedChains === []) { $message = 'Could not resolve trust chains or no common trust anchors found.'; $this->logger?->error($message, $debugStartInfo); throw new TrustChainException($message); @@ -284,20 +288,21 @@ public function for(string $entityId, array $validTrustAnchorIds): TrustChainBag /** * @throws \SimpleSAML\OpenID\Exceptions\TrustChainException + * @phpstan-ignore missingType.iterableValue (We validate it here) */ protected function validateStart(string $entityId, array $validTrustAnchorIds): void { $errors = []; - if (empty($entityId)) { + if ($entityId === '' || $entityId === '0') { $errors[] = 'Empty entity ID.'; } - if (empty($validTrustAnchorIds)) { + if ($validTrustAnchorIds === []) { $errors[] = 'No valid Trust Anchors provided.'; } - if (!empty($errors)) { + if ($errors !== []) { $message = 'Validation errors encountered: ' . implode(', ', $errors); $this->logger?->error($message); throw new TrustChainException($message); diff --git a/src/Federation/TrustMark.php b/src/Federation/TrustMark.php index b6ecc39..406c800 100644 --- a/src/Federation/TrustMark.php +++ b/src/Federation/TrustMark.php @@ -42,7 +42,6 @@ public function getIdentifier(): string } /** - * @return int * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\TrustMarkException */ diff --git a/src/Helpers/Arr.php b/src/Helpers/Arr.php index 24ffbc8..95a5700 100644 --- a/src/Helpers/Arr.php +++ b/src/Helpers/Arr.php @@ -8,6 +8,9 @@ class Arr { + /** + * @phpstan-ignore missingType.iterableValue (We can handle mixed type) + */ public function ensureArrayDepth(array &$array, int|string ...$keys): void { if (count($keys) > 99) { @@ -27,4 +30,16 @@ public function ensureArrayDepth(array &$array, int|string ...$keys): void $this->ensureArrayDepth($array[$key], ...$keys); } + + /** + * @return array + * @phpstan-ignore missingType.iterableValue (We can handle mixed type) + */ + public function ensureStringKeys(array $array): array + { + return array_combine( + array_map('strval', array_keys($array)), + $array, + ); + } } diff --git a/src/Helpers/Url.php b/src/Helpers/Url.php index 03aa0d9..370341f 100644 --- a/src/Helpers/Url.php +++ b/src/Helpers/Url.php @@ -14,14 +14,11 @@ public function isValid(string $url): bool /** * Add (new) params to URL while preserving existing ones (if any). - * - * @param string $url - * @param array $params - * @return string + * @param array $params */ public function withParams(string $url, array $params): string { - if (empty($params)) { + if ($params === []) { return $url; } diff --git a/src/Jwks.php b/src/Jwks.php index 796f339..c5b3def 100644 --- a/src/Jwks.php +++ b/src/Jwks.php @@ -27,42 +27,39 @@ class Jwks { - protected DateIntervalDecorator $maxCacheDuration; - protected DateIntervalDecorator $timestampValidationLeeway; + protected DateIntervalDecorator $maxCacheDurationDecorator; + protected DateIntervalDecorator $timestampValidationLeewayDecorator; protected ?CacheDecorator $cacheDecorator; protected ?JwksFetcher $jwksFetcher = null; protected HttpClientDecorator $httpClientDecorator; - protected JwsSerializerManager $jwsSerializerManager; - protected JwsParser $jwsParser; - protected JwsVerifier $jwsVerifier; + protected ?JwsSerializerManager $jwsSerializerManager = null; + protected ?JwsParser $jwsParser = null; + protected ?JwsVerifier $jwsVerifier = null; protected ?JwksFactory $jwksFactory = null; protected ?SignedJwksFactory $signedJwksFactory = null; + protected ?Helpers $helpers = null; + protected ?AlgorithmManagerFactory $algorithmManagerFactory = null; + protected ?JwsSerializerManagerFactory $jwsSerializerManagerFactory = null; + protected ?JwsParserFactory $jwsParserFactory = null; + protected ?JwsVerifierFactory $jwsVerifierFactory = null; + protected ?DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = null; + protected ?CacheDecoratorFactory $cacheDecoratorFactory = null; + protected ?HttpClientDecoratorFactory $httpClientDecoratorFactory = null; public function __construct( protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(), protected readonly SupportedSerializers $supportedSerializers = new SupportedSerializers(), DateInterval $maxCacheDuration = new DateInterval('PT1H'), + DateInterval $timestampValidationLeeway = new DateInterval('PT1M'), ?CacheInterface $cache = null, - ?Client $httpClient = null, protected readonly ?LoggerInterface $logger = null, - protected readonly Helpers $helpers = new Helpers(), - AlgorithmManagerFactory $algorithmManagerFactory = new AlgorithmManagerFactory(), - JwsSerializerManagerFactory $jwsSerializerManagerFactory = new JwsSerializerManagerFactory(), - JwsParserFactory $jwsParserFactory = new JwsParserFactory(), - JwsVerifierFactory $jwsVerifierFactory = new JwsVerifierFactory(), - DateInterval $timestampValidationLeeway = new DateInterval('PT1M'), - DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = new DateIntervalDecoratorFactory(), - CacheDecoratorFactory $cacheDecoratorFactory = new CacheDecoratorFactory(), - HttpClientDecoratorFactory $httpClientDecoratorFactory = new HttpClientDecoratorFactory(), + ?Client $httpClient = null, ) { - $this->maxCacheDuration = $dateIntervalDecoratorFactory->build($maxCacheDuration); - $this->timestampValidationLeeway = $dateIntervalDecoratorFactory->build($timestampValidationLeeway); - $this->cacheDecorator = is_null($cache) ? null : $cacheDecoratorFactory->build($cache); - $this->httpClientDecorator = $httpClientDecoratorFactory->build($httpClient); - - $this->jwsSerializerManager = $jwsSerializerManagerFactory->build($this->supportedSerializers); - $this->jwsParser = $jwsParserFactory->build($this->jwsSerializerManager); - $this->jwsVerifier = $jwsVerifierFactory->build($algorithmManagerFactory->build($this->supportedAlgorithms)); + $this->maxCacheDurationDecorator = $this->dateIntervalDecoratorFactory()->build($maxCacheDuration); + $this->timestampValidationLeewayDecorator = $this->dateIntervalDecoratorFactory() + ->build($timestampValidationLeeway); + $this->cacheDecorator = is_null($cache) ? null : $this->cacheDecoratorFactory()->build($cache); + $this->httpClientDecorator = $this->httpClientDecoratorFactory()->build($httpClient); } public function jwksFactory(): JwksFactory @@ -73,12 +70,12 @@ public function jwksFactory(): JwksFactory public function signedJwksFactory(): SignedJwksFactory { return $this->signedJwksFactory ??= new SignedJwksFactory( - $this->jwsParser, - $this->jwsVerifier, + $this->jwsParser(), + $this->jwsVerifier(), $this->jwksFactory(), - $this->jwsSerializerManager, - $this->timestampValidationLeeway, - $this->helpers, + $this->jwsSerializerManager(), + $this->timestampValidationLeewayDecorator, + $this->helpers(), ); } @@ -88,10 +85,91 @@ public function jwksFetcher(): JwksFetcher $this->httpClientDecorator, $this->jwksFactory(), $this->signedJwksFactory(), - $this->maxCacheDuration, + $this->maxCacheDurationDecorator, + $this->helpers(), $this->cacheDecorator, $this->logger, - $this->helpers, ); } + + public function helpers(): Helpers + { + return $this->helpers ??= new Helpers(); + } + + public function algorithmManagerFactory(): AlgorithmManagerFactory + { + if (is_null($this->algorithmManagerFactory)) { + $this->algorithmManagerFactory = new AlgorithmManagerFactory(); + } + return $this->algorithmManagerFactory; + } + + public function jwsSerializerManagerFactory(): JwsSerializerManagerFactory + { + if (is_null($this->jwsSerializerManagerFactory)) { + $this->jwsSerializerManagerFactory = new JwsSerializerManagerFactory(); + } + return $this->jwsSerializerManagerFactory; + } + + public function jwsParserFactory(): JwsParserFactory + { + if (is_null($this->jwsParserFactory)) { + $this->jwsParserFactory = new JwsParserFactory(); + } + return $this->jwsParserFactory; + } + + public function jwsVerifierFactory(): JwsVerifierFactory + { + if (is_null($this->jwsVerifierFactory)) { + $this->jwsVerifierFactory = new JwsVerifierFactory(); + } + return $this->jwsVerifierFactory; + } + + public function dateIntervalDecoratorFactory(): DateIntervalDecoratorFactory + { + if (is_null($this->dateIntervalDecoratorFactory)) { + $this->dateIntervalDecoratorFactory = new DateIntervalDecoratorFactory(); + } + + return $this->dateIntervalDecoratorFactory; + } + + public function cacheDecoratorFactory(): CacheDecoratorFactory + { + if (is_null($this->cacheDecoratorFactory)) { + $this->cacheDecoratorFactory = new CacheDecoratorFactory(); + } + + return $this->cacheDecoratorFactory; + } + + public function httpClientDecoratorFactory(): HttpClientDecoratorFactory + { + if (is_null($this->httpClientDecoratorFactory)) { + $this->httpClientDecoratorFactory = new HttpClientDecoratorFactory(); + } + + return $this->httpClientDecoratorFactory; + } + + public function jwsVerifier(): JwsVerifier + { + return $this->jwsVerifier ??= $this->jwsVerifierFactory()->build( + $this->algorithmManagerFactory()->build($this->supportedAlgorithms), + ); + } + + public function jwsParser(): JwsParser + { + return $this->jwsParser ??= $this->jwsParserFactory()->build($this->jwsSerializerManager()); + } + + public function jwsSerializerManager(): JwsSerializerManager + { + return $this->jwsSerializerManager ??= $this->jwsSerializerManagerFactory()->build($this->supportedSerializers); + } } diff --git a/src/Jwks/Factories/JwksFactory.php b/src/Jwks/Factories/JwksFactory.php index d075649..8cd2b95 100644 --- a/src/Jwks/Factories/JwksFactory.php +++ b/src/Jwks/Factories/JwksFactory.php @@ -9,6 +9,9 @@ class JwksFactory { + /** + * @phpstan-ignore missingType.iterableValue (JWKS array is validated later) + */ public function fromKeyData(array $jwks): JwksDecorator { return new JwksDecorator(JWKSet::createFromKeyData($jwks)); diff --git a/src/Jwks/JwksDecorator.php b/src/Jwks/JwksDecorator.php index 4297440..ea00d65 100644 --- a/src/Jwks/JwksDecorator.php +++ b/src/Jwks/JwksDecorator.php @@ -20,6 +20,10 @@ public function jwks(): JWKSet return $this->jwks; } + /** + * @return array{keys:array>} + * @psalm-suppress MixedReturnTypeCoercion, MixedReturnTypeCoercion + */ public function jsonSerialize(): array { return [ diff --git a/src/Jwks/JwksFetcher.php b/src/Jwks/JwksFetcher.php index 0a6baa2..924b9ea 100644 --- a/src/Jwks/JwksFetcher.php +++ b/src/Jwks/JwksFetcher.php @@ -23,14 +23,15 @@ public function __construct( protected readonly HttpClientDecorator $httpClientDecorator, protected readonly JwksFactory $jwksFactory, protected readonly SignedJwksFactory $signedJwksFactory, - protected readonly DateIntervalDecorator $maxCacheDuration, + protected readonly DateIntervalDecorator $maxCacheDurationDecorator, + protected readonly Helpers $helpers, protected readonly ?CacheDecorator $cacheDecorator = null, protected readonly ?LoggerInterface $logger = null, - protected readonly Helpers $helpers = new Helpers(), ) { } /** + * @return array{keys:array>} * @throws \SimpleSAML\OpenID\Exceptions\JwksException */ protected function decodeJwksJson(string $jwksJson): array @@ -41,14 +42,14 @@ protected function decodeJwksJson(string $jwksJson): array $message = 'Error trying to decode JWKS JSON document: ' . $exception->getMessage(); $this->logger?->error( 'Error trying to decode JWKS JSON document: ' . $exception->getMessage(), - compact('jwksJson'), + ['jwksJson' => $jwksJson], ); throw new JwksException($message); } if (!is_array($jwks)) { $message = sprintf('Unexpected JWKS type: %s.', var_export($jwks, true)); - $this->logger?->error($message, compact('jwks')); + $this->logger?->error($message, ['jwks' => $jwks]); throw new JwksException($message); } @@ -58,10 +59,16 @@ protected function decodeJwksJson(string $jwksJson): array empty($jwks[ClaimsEnum::Keys->value]) ) { $message = sprintf('Unexpected JWKS format: %s.', var_export($jwks, true)); - $this->logger?->error($message, compact('jwks')); + $this->logger?->error($message, ['jwks' => $jwks]); throw new JwksException($message); } + $jwks[ClaimsEnum::Keys->value] = array_map( + $this->helpers->arr()->ensureStringKeys(...), + $jwks[ClaimsEnum::Keys->value], + ); + + /** @var array{keys:array>} $jwks */ return $jwks; } @@ -69,7 +76,7 @@ public function fromCache(string $uri): ?JwksDecorator { $this->logger?->debug( 'Trying to get JWKS document from cache.', - compact('uri'), + ['uri' => $uri], ); try { @@ -78,19 +85,19 @@ public function fromCache(string $uri): ?JwksDecorator } catch (Throwable $exception) { $this->logger?->error( 'Error trying to get JWKS document from cache: ' . $exception->getMessage(), - compact('uri'), + ['uri' => $uri], ); return null; } if (!is_string($jwksJson)) { - $this->logger?->debug('JWKS JSON not fount in cache.', compact('uri')); + $this->logger?->debug('JWKS JSON not found in cache.', ['uri' => $uri]); return null; } $this->logger?->debug( 'JWKS JSON found in cache, trying to decode it.', - compact('uri', 'jwksJson'), + ['uri' => $uri, 'jwksJson' => $jwksJson], ); try { @@ -98,12 +105,12 @@ public function fromCache(string $uri): ?JwksDecorator } catch (JwksException $exception) { $this->logger?->error( 'Error trying to decode JWKS JSON: ' . $exception->getMessage(), - compact('uri', 'jwksJson'), + ['uri' => $uri, 'jwksJson' => $jwksJson], ); return null; } - $this->logger?->debug('JWKS JSON decoded, proceeding to instance building.', compact('uri', 'jwks')); + $this->logger?->debug('JWKS JSON decoded, proceeding to instance building.', ['uri' => $uri, 'jwks' => $jwks]); return $this->jwksFactory->fromKeyData($jwks); } @@ -119,19 +126,19 @@ public function fromCacheOrJwksUri(string $uri): ?JwksDecorator */ public function fromJwksUri(string $uri): ?JwksDecorator { - $this->logger?->debug('Trying to get JWKS from URI.', compact('uri')); + $this->logger?->debug('Trying to get JWKS from URI.', ['uri' => $uri]); try { $response = $this->httpClientDecorator->request(HttpMethodsEnum::GET, $uri); } catch (HttpException $e) { - $this->logger?->error('Error trying to get JWKS from URI: ' . $e->getMessage(), compact('uri')); + $this->logger?->error('Error trying to get JWKS from URI: ' . $e->getMessage(), ['uri' => $uri]); return null; } $jwksJson = $response->getBody()->getContents(); $this->logger?->info( 'Successful HTTP response for JWKS URI fetch, trying to decode it.', - compact('uri', 'jwksJson'), + ['uri' => $uri, 'jwksJson' => $jwksJson], ); try { @@ -139,34 +146,35 @@ public function fromJwksUri(string $uri): ?JwksDecorator } catch (JwksException $exception) { $this->logger?->error( 'Error trying to decode JWKS document: ' . $exception->getMessage(), - compact('uri', 'jwksJson'), + ['uri' => $uri, 'jwksJson' => $jwksJson], ); return null; } - $this->logger?->debug('JWKS JSON decoded, saving it to cache.', compact('uri', 'jwks')); + $this->logger?->debug('JWKS JSON decoded, saving it to cache.', ['uri' => $uri, 'jwks' => $jwks]); try { $this->cacheDecorator?->set( $jwksJson, - $this->maxCacheDuration->getInSeconds(), + $this->maxCacheDurationDecorator->getInSeconds(), $uri, ); - $this->logger?->debug('JWKS JSON saved to cache.', compact('uri', 'jwks')); + $this->logger?->debug('JWKS JSON saved to cache.', ['uri' => $uri, 'jwks' => $jwks]); } catch (Throwable $exception) { $this->logger?->error( 'Error setting JWKS JSON to cache: ' . $exception->getMessage(), - compact('uri', $jwksJson), + ['uri' => $uri, 'jwksJson' => $jwksJson], ); } - $this->logger?->debug('Proceeding to instance building.', compact('uri', 'jwks')); + $this->logger?->debug('Proceeding to instance building.', ['uri' => $uri, 'jwks' => $jwks]); return $this->jwksFactory->fromKeyData($jwks); } /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @phpstan-ignore missingType.iterableValue (JWKS array is validated later) */ public function fromCacheOrSignedJwksUri(string $uri, array $federationJwks): ?JwksDecorator { @@ -177,47 +185,48 @@ public function fromCacheOrSignedJwksUri(string $uri, array $federationJwks): ?J * @param string $uri URI from which to fetch SignedJwks statement. * @param array $federationJwks Federation JWKS which will be used to check signature on SignedJwks statement. * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @phpstan-ignore missingType.iterableValue (JWKS array is validated later) */ public function fromSignedJwksUri(string $uri, array $federationJwks): ?JwksDecorator { - $this->logger?->debug('Trying to get Signed JWKS from URI.', compact('uri')); + $this->logger?->debug('Trying to get Signed JWKS from URI.', ['uri' => $uri]); try { $response = $this->httpClientDecorator->request(HttpMethodsEnum::GET, $uri); } catch (HttpException $e) { - $this->logger?->error('Error trying to get Signed JWKS from URI: ' . $e->getMessage(), compact('uri')); + $this->logger?->error('Error trying to get Signed JWKS from URI: ' . $e->getMessage(), ['uri' => $uri]); return null; } $token = $response->getBody()->getContents(); - $this->logger?->info('Successful HTTP response for Signed JWKS fetch.', compact('uri', 'token')); + $this->logger?->info('Successful HTTP response for Signed JWKS fetch.', ['uri' => $uri, 'token' => $token]); $this->logger?->debug('Proceeding to Signed JWKS instance building.'); $signedJwks = $this->signedJwksFactory->fromToken($token); $this->logger?->debug( 'Signed JWKS instance built. Trying to verify signature.', - compact('uri', 'token'), + ['uri' => $uri, 'token' => $token], ); $signedJwks->verifyWithKeySet($federationJwks); - $this->logger?->debug('Signed JWKS signature verified.', compact('uri', 'token')); + $this->logger?->debug('Signed JWKS signature verified.', ['uri' => $uri, 'token' => $token]); try { $jwksJson = $this->helpers->json()->encode($signedJwks->jsonSerialize()); - $this->logger?->debug('Signed JWKS JSON decoded.', compact('uri', 'jwksJson')); + $this->logger?->debug('Signed JWKS JSON decoded.', ['uri' => $uri, 'jwksJson' => $jwksJson]); $signedJwksExpirationTime = $signedJwks->getExpirationTime(); $cacheTtl = is_null($signedJwksExpirationTime) ? - $this->maxCacheDuration->getInSeconds() : - $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime($signedJwksExpirationTime); + $this->maxCacheDurationDecorator->getInSeconds() : + $this->maxCacheDurationDecorator->lowestInSecondsComparedToExpirationTime($signedJwksExpirationTime); $this->cacheDecorator?->set($jwksJson, $cacheTtl, $uri); $this->logger?->debug( 'Signed JWKS JSON successfully cached.', - compact('uri', 'jwksJson', 'cacheTtl'), + ['uri' => $uri, 'jwksJson' => $jwksJson, 'cacheTtl' => $cacheTtl], ); } catch (Throwable $exception) { $this->logger?->error( 'Error setting Signed JWKS JSON to cache: ' . $exception->getMessage(), - compact('uri'), + ['uri' => $uri], ); } diff --git a/src/Jwks/SignedJwks.php b/src/Jwks/SignedJwks.php index 3bd54b0..621b172 100644 --- a/src/Jwks/SignedJwks.php +++ b/src/Jwks/SignedJwks.php @@ -13,6 +13,7 @@ class SignedJwks extends ParsedJws implements JsonSerializable { /** + * @return array> * @throws \SimpleSAML\OpenID\Exceptions\JwsException * @throws \SimpleSAML\OpenID\Exceptions\SignedJwksException */ @@ -22,13 +23,16 @@ public function getKeys(): array 'No keys claim found.', ); - if ((!is_array($keys)) || empty($keys)) { + if ((!is_array($keys)) || $keys === []) { throw new SignedJwksException( sprintf('Unexpected JWKS keys claim format: %s.', var_export($keys, true)), ); } - return $keys; + return array_map( + $this->helpers->arr()->ensureStringKeys(...), + $keys, + ); } /** @@ -69,7 +73,6 @@ public function getType(): string /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException - * @throws \SimpleSAML\OpenID\Exceptions\SignedJwksException */ protected function validate(): void { @@ -83,6 +86,11 @@ protected function validate(): void ); } + /** + * @return array{keys: array>} + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\SignedJwksException + */ public function jsonSerialize(): array { return [ClaimsEnum::Keys->value => $this->getKeys()]; diff --git a/src/Jws/AbstractJwsFetcher.php b/src/Jws/AbstractJwsFetcher.php new file mode 100644 index 0000000..4fa20ef --- /dev/null +++ b/src/Jws/AbstractJwsFetcher.php @@ -0,0 +1,27 @@ +parsedJwsFactory->fromToken($token); + } + + public function getExpectedContentTypeHttpHeader(): ?string + { + return null; + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + */ + public function fromCacheOrNetwork(string $uri): ParsedJws + { + return $this->fromCache($uri) ?? $this->fromNetwork($uri); + } + + /** + * Fetch JWS from cache, if available. URI is used as cache key. + * + * @throws \SimpleSAML\OpenID\Exceptions\JwsException + */ + public function fromCache(string $uri): ?ParsedJws + { + $this->logger?->debug( + 'Trying to get JWS token from cache.', + ['uri' => $uri], + ); + + $jws = $this->artifactFetcher->fromCacheAsString($uri); + + if (!is_string($jws)) { + $this->logger?->debug('JWS token not found in cache.', ['uri' => $uri]); + return null; + } + + $this->logger?->debug( + 'JWS token found in cache, trying to build instance.', + ['uri' => $uri], + ); + + return $this->buildJwsInstance($jws); + } + + /** + * Fetch JWS from network. 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 + { + $this->logger?->debug( + 'Trying to fetch JWS token from network.', + ['uri' => $uri], + ); + + $response = $this->artifactFetcher->fromNetwork($uri); + + if ($response->getStatusCode() !== 200) { + $message = sprintf( + 'Unexpected HTTP response for JWS fetch, status code: %s, reason: %s. URI %s', + $response->getStatusCode(), + $response->getReasonPhrase(), + $uri, + ); + $this->logger?->error($message); + throw new FetchException($message); + } + + /** @psalm-suppress InvalidLiteralArgument */ + if ( + is_string($expectedContentTypeHttpHeader = $this->getExpectedContentTypeHttpHeader()) && + (!str_contains( + $response->getHeaderLine(HttpHeadersEnum::ContentType->value), + $expectedContentTypeHttpHeader, + )) + ) { + $message = sprintf( + 'Unexpected content type in response for JWS fetch: %s, expected: %s. URI %s', + $response->getHeaderLine(HttpHeadersEnum::ContentType->value), + $expectedContentTypeHttpHeader, + $uri, + ); + $this->logger?->error($message); + throw new FetchException($message); + } + + $token = $response->getBody()->getContents(); + $this->logger?->debug('Successful HTTP response for JWS fetch.', ['uri' => $uri, 'token' => $token]); + $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]); + + $cacheTtl = is_int($expirationTime = $jwsInstance->getExpirationTime()) ? + $this->maxCacheDuration->lowestInSecondsComparedToExpirationTime( + $expirationTime, + ) : + $this->maxCacheDuration->getInSeconds(); + + $this->artifactFetcher->cacheIt($token, $cacheTtl, $uri); + + $this->logger?->debug('Returning built JWS instance.', ['uri' => $uri, 'token' => $token]); + + return $jwsInstance; + } +} diff --git a/src/Jws/ParsedJws.php b/src/Jws/ParsedJws.php index 979351c..ef9f64c 100644 --- a/src/Jws/ParsedJws.php +++ b/src/Jws/ParsedJws.php @@ -22,6 +22,9 @@ class ParsedJws * @var array */ protected ?array $header = null; + /** + * @var array + */ protected ?array $payload = null; protected ?string $token = null; @@ -53,11 +56,11 @@ protected function validateByCallbacks(callable ...$calls): void try { call_user_func($call); } catch (Throwable $exception) { - $errors[] = sprintf('%s: %s', get_class($exception), $exception->getMessage()); + $errors[] = sprintf('%s: %s', $exception::class, $exception->getMessage()); } } - if (!empty($errors)) { + if ($errors !== []) { throw new JwsException('JWS not valid: ' . implode('; ', $errors)); } } @@ -70,7 +73,7 @@ protected function ensureNonEmptyString(mixed $value, string $description): stri { $value = (string)$value; - if (empty($value)) { + if ($value === '' || $value === '0') { $message = "Empty string value encountered: $description"; throw new JwsException($message); } @@ -79,9 +82,8 @@ protected function ensureNonEmptyString(mixed $value, string $description): stri } /** - * @param array $values - * @param string $description * @return non-empty-string[] + * @phpstan-ignore missingType.iterableValue (We cast everything to string) */ protected function ensureNonEmptyStrings(array $values, string $description): array { @@ -109,7 +111,6 @@ public function getHeader(int $signatureId = 0): array /** * @param non-empty-string $key - * @return mixed * @throws \SimpleSAML\OpenID\Exceptions\JwsException */ public function getHeaderClaim(string $key): mixed @@ -138,6 +139,7 @@ public function getToken( /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @return array */ public function getPayload(): array { @@ -146,13 +148,12 @@ public function getPayload(): array } $payloadString = $this->jws->getPayload(); - /** @psalm-suppress RiskyTruthyFalsyComparison */ - if (empty($payloadString)) { + if ($payloadString === null || $payloadString === '' || $payloadString === '0') { return $this->payload = []; } try { - /** @var ?array $payload */ + /** @var ?array $payload */ $payload = $this->helpers->json()->decode($payloadString); return $this->payload = is_array($payload) ? $payload : []; } catch (JsonException $exception) { @@ -162,14 +163,19 @@ public function getPayload(): array /** * @throws \SimpleSAML\OpenID\Exceptions\JwsException + * @phpstan-ignore missingType.iterableValue (JWKS array is validated later) */ public function verifyWithKeySet(array $jwks, int $signatureIndex = 0): void { - $this->jwsVerifier->verifyWithKeySet( - $this->jws, - $this->jwksFactory->fromKeyData($jwks)->jwks(), - $signatureIndex, - ) || throw new JwsException('Could not verify JWS signature.'); + if ( + !$this->jwsVerifier->verifyWithKeySet( + $this->jws, + $this->jwksFactory->fromKeyData($jwks)->jwks(), + $signatureIndex, + ) + ) { + throw new JwsException('Could not verify JWS signature.'); + } } /** @@ -254,8 +260,9 @@ public function getExpirationTime(): ?int $exp = (int)$exp; - ($exp + $this->timestampValidationLeeway->getInSeconds() >= time()) || - throw new JwsException("Expiration Time claim ($exp) is lesser than current time."); + if ($exp + $this->timestampValidationLeeway->getInSeconds() < time()) { + throw new JwsException("Expiration Time claim ($exp) is lesser than current time."); + } return $exp; } @@ -274,8 +281,9 @@ public function getIssuedAt(): ?int $iat = (int)$iat; - ($iat - $this->timestampValidationLeeway->getInSeconds() <= time()) || - throw new JwsException("Issued At claim ($iat) is greater than current time."); + if ($iat - $this->timestampValidationLeeway->getInSeconds() > time()) { + throw new JwsException("Issued At claim ($iat) is greater than current time."); + } return $iat; } diff --git a/src/Serializers/JwsSerializerBag.php b/src/Serializers/JwsSerializerBag.php index d9ed919..92703ed 100644 --- a/src/Serializers/JwsSerializerBag.php +++ b/src/Serializers/JwsSerializerBag.php @@ -4,6 +4,8 @@ namespace SimpleSAML\OpenID\Serializers; +use Jose\Component\Signature\Serializer\JWSSerializer; + class JwsSerializerBag { /** @@ -35,9 +37,7 @@ public function getAll(): array public function getAllInstances(): array { return array_map( - function (JwsSerializerEnum $jwsSerializerEnum) { - return $jwsSerializerEnum->instance(); - }, + fn(JwsSerializerEnum $jwsSerializerEnum): JWSSerializer => $jwsSerializerEnum->instance(), $this->getAll(), ); } diff --git a/src/SupportedSerializers.php b/src/SupportedSerializers.php index 99a7030..8bc5b92 100644 --- a/src/SupportedSerializers.php +++ b/src/SupportedSerializers.php @@ -16,9 +16,6 @@ public function __construct( ) { } - /** - * @return \SimpleSAML\OpenID\Serializers\JwsSerializerBag - */ public function getJwsSerializerBag(): JwsSerializerBag { return $this->jwsSerializerBag; diff --git a/src/Utils/ArtifactFetcher.php b/src/Utils/ArtifactFetcher.php new file mode 100644 index 0000000..eaea306 --- /dev/null +++ b/src/Utils/ArtifactFetcher.php @@ -0,0 +1,138 @@ +cacheDecorator)) { + $this->logger?->debug( + 'Cache instance not available, skipping cache query.', + ['keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + return null; + } + + try { + /** @psalm-suppress MixedAssignment */ + $artifact = $this->cacheDecorator->get(null, $keyElement, ...$keyElements); + } catch (Throwable $exception) { + $this->logger?->error( + 'Error trying to get artifact from cache: ' . $exception->getMessage(), + ['keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + return null; + } + + if (is_null($artifact)) { + $this->logger?->debug( + 'Artifact not found in cache.', + ['keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + return null; + } + + if (is_string($artifact)) { + $this->logger?->debug( + 'Artifact found in cache, returning.', + ['artifact' => $artifact, 'keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + return $artifact; + } + + $this->logger?->warning( + 'Unexpected value for cached artifact (expected string).', + ['artifact' => $artifact, 'keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + + return null; + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + */ + public function fromNetwork(string $uri): ResponseInterface + { + $this->logger?->debug('Fetching artifact on network from URI.', ['uri' => $uri]); + try { + $response = $this->httpClientDecorator->request(HttpMethodsEnum::GET, $uri); + } catch (Throwable $e) { + $message = sprintf( + 'Error sending HTTP request to %s. Error was: %s', + $uri, + $e->getMessage(), + ); + $this->logger?->error($message); + throw new FetchException($message, (int)$e->getCode(), $e); + } + + $this->logger?->debug('Artifact fetched on network from URI, returning HTTP response.', ['uri' => $uri]); + + return $response; + } + + /** + * @throws \SimpleSAML\OpenID\Exceptions\FetchException + */ + public function fromNetworkAsString(string $uri): string + { + $this->logger?->debug('Fetching artifact on network from URI (as string).', ['uri' => $uri]); + + $artifact = $this->fromNetwork($uri)->getBody()->getContents(); + + $this->logger?->debug( + 'Fetched artifact on network from URI as string.', + ['artifact' => $artifact, 'uri' => $uri], + ); + + return $artifact; + } + + public function cacheIt(string $artifact, int|DateInterval $ttl, string $keyElement, string ...$keyElements): void + { + if (is_null($this->cacheDecorator)) { + $this->logger?->debug( + 'Cache instance not available, skipping caching.', + ['artifact' => $artifact, 'ttl' => $ttl, 'keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + return; + } + + try { + $this->cacheDecorator->set( + $artifact, + $ttl, + $keyElement, + ...$keyElements, + ); + $this->logger?->debug( + 'Artifact saved to cache.', + ['artifact' => $artifact, 'ttl' => $ttl, 'keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + } catch (Throwable $exception) { + $this->logger?->error( + 'Error saving artifact to cache: ' . $exception->getMessage(), + ['artifact' => $artifact, 'ttl' => $ttl, 'keyElement' => $keyElement, 'keyElements' => $keyElements], + ); + } + } +} diff --git a/tests/src/Algorithms/SignatureAlgorithmBagTest.php b/tests/src/Algorithms/SignatureAlgorithmBagTest.php index 43213b5..e769285 100644 --- a/tests/src/Algorithms/SignatureAlgorithmBagTest.php +++ b/tests/src/Algorithms/SignatureAlgorithmBagTest.php @@ -23,9 +23,9 @@ protected function setUp(): void protected function sut(SignatureAlgorithmEnum ...$signatureAlgorithmEnums): SignatureAlgorithmBag { - $signatureAlgorithmEnums = !empty($signatureAlgorithmEnums) ? - $signatureAlgorithmEnums : - [$this->signatureAlgorithmEnumRs256]; + $signatureAlgorithmEnums = $signatureAlgorithmEnums === [] ? + [$this->signatureAlgorithmEnumRs256] : + $signatureAlgorithmEnums; return new SignatureAlgorithmBag(...$signatureAlgorithmEnums); } diff --git a/tests/src/CoreTest.php b/tests/src/CoreTest.php index 423b752..6e580ad 100644 --- a/tests/src/CoreTest.php +++ b/tests/src/CoreTest.php @@ -13,14 +13,14 @@ use SimpleSAML\OpenID\Core; use SimpleSAML\OpenID\Core\Factories\ClientAssertionFactory; use SimpleSAML\OpenID\Core\Factories\RequestObjectFactory; +use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Factories\AlgorithmManagerFactory; use SimpleSAML\OpenID\Factories\DateIntervalDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerFactory; -use SimpleSAML\OpenID\Helpers; -use SimpleSAML\OpenID\Jwks\Factories\JwksFactory; use SimpleSAML\OpenID\Jws\Factories\JwsParserFactory; use SimpleSAML\OpenID\Jws\Factories\JwsVerifierFactory; use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; +use SimpleSAML\OpenID\Jws\JwsParser; use SimpleSAML\OpenID\SupportedAlgorithms; use SimpleSAML\OpenID\SupportedSerializers; @@ -28,33 +28,26 @@ #[UsesClass(ParsedJwsFactory::class)] #[UsesClass(RequestObjectFactory::class)] #[UsesClass(ClientAssertionFactory::class)] +#[UsesClass(DateIntervalDecorator::class)] +#[UsesClass(DateIntervalDecoratorFactory::class)] +#[UsesClass(AlgorithmManagerFactory::class)] +#[UsesClass(JwsSerializerManagerFactory::class)] +#[UsesClass(JwsParserFactory::class)] +#[UsesClass(JwsVerifierFactory::class)] +#[UsesClass(JwsParser::class)] class CoreTest extends TestCase { protected MockObject $supportedAlgorithmsMock; protected MockObject $supportedSerializerMock; - protected MockObject $timestampValidationLeewayMock; + protected DateInterval $timestampValidationLeeway; protected MockObject $loggerMock; - protected MockObject $helpersMock; - protected MockObject $algorithmManagerFactoryMock; - protected MockObject $jwsSerializerManagerFactoryMock; - protected MockObject $jwsParserFactoryMock; - protected MockObject $jwsVerifierFactoryMock; - protected MockObject $jwksFactoryMock; - protected MockObject $dateIntervalDecoratorFactoryMock; protected function setUp(): void { $this->supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); $this->supportedSerializerMock = $this->createMock(SupportedSerializers::class); - $this->timestampValidationLeewayMock = $this->createMock(DateInterval::class); + $this->timestampValidationLeeway = new DateInterval('PT1M'); $this->loggerMock = $this->createMock(LoggerInterface::class); - $this->helpersMock = $this->createMock(Helpers::class); - $this->algorithmManagerFactoryMock = $this->createMock(AlgorithmManagerFactory::class); - $this->jwsSerializerManagerFactoryMock = $this->createMock(JwsSerializerManagerFactory::class); - $this->jwsParserFactoryMock = $this->createMock(JwsParserFactory::class); - $this->jwsVerifierFactoryMock = $this->createMock(JwsVerifierFactory::class); - $this->jwksFactoryMock = $this->createMock(JwksFactory::class); - $this->dateIntervalDecoratorFactoryMock = $this->createMock(DateIntervalDecoratorFactory::class); } protected function sut( @@ -62,38 +55,17 @@ protected function sut( ?SupportedSerializers $supportedSerializers = null, ?DateInterval $timestampValidationLeeway = null, ?LoggerInterface $logger = null, - ?Helpers $helpers = null, - ?AlgorithmManagerFactory $algorithmManagerFactory = null, - ?JwsSerializerManagerFactory $jwsSerializerManagerFactory = null, - ?JwsParserFactory $jwsParserFactory = null, - ?JwsVerifierFactory $jwsVerifierFactory = null, - ?JwksFactory $jwksFactory = null, - ?DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = null, ): Core { $supportedAlgorithms ??= $this->supportedAlgorithmsMock; $supportedSerializers ??= $this->supportedSerializerMock; - $timestampValidationLeeway ??= $this->timestampValidationLeewayMock; + $timestampValidationLeeway ??= $this->timestampValidationLeeway; $logger ??= $this->loggerMock; - $helpers ??= $this->helpersMock; - $algorithmManagerFactory ??= $this->algorithmManagerFactoryMock; - $jwsSerializerManagerFactory ??= $this->jwsSerializerManagerFactoryMock; - $jwsParserFactory ??= $this->jwsParserFactoryMock; - $jwsVerifierFactory ??= $this->jwsVerifierFactoryMock; - $jwksFactory ??= $this->jwksFactoryMock; - $dateIntervalDecoratorFactory ??= $this->dateIntervalDecoratorFactoryMock; return new Core( $supportedAlgorithms, $supportedSerializers, $timestampValidationLeeway, $logger, - $helpers, - $algorithmManagerFactory, - $jwsSerializerManagerFactory, - $jwsParserFactory, - $jwsVerifierFactory, - $jwksFactory, - $dateIntervalDecoratorFactory, ); } diff --git a/tests/src/Federation/EntityStatementFetcherTest.php b/tests/src/Federation/EntityStatementFetcherTest.php index 49a900c..2d6ec36 100644 --- a/tests/src/Federation/EntityStatementFetcherTest.php +++ b/tests/src/Federation/EntityStatementFetcherTest.php @@ -5,58 +5,69 @@ 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\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; -use SimpleSAML\OpenID\Decorators\CacheDecorator; +use SimpleSAML\OpenID\Codebooks\ContentTypesEnum; +use SimpleSAML\OpenID\Codebooks\WellKnownEnum; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; -use SimpleSAML\OpenID\Decorators\HttpClientDecorator; +use SimpleSAML\OpenID\Exceptions\EntityStatementException; use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Federation\EntityStatementFetcher; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Helpers; +use SimpleSAML\OpenID\Jws\AbstractJwsFetcher; +use SimpleSAML\OpenID\Jws\JwsFetcher; +use SimpleSAML\OpenID\Utils\ArtifactFetcher; #[CoversClass(EntityStatementFetcher::class)] +#[UsesClass(AbstractJwsFetcher::class)] +#[UsesClass(JwsFetcher::class)] +#[UsesClass(WellKnownEnum::class)] class EntityStatementFetcherTest extends TestCase { - protected MockObject $httpClientDecoratorMock; protected MockObject $entityStatementFactoryMock; + protected MockObject $artifactFetcherMock; protected MockObject $maxCacheDurationMock; protected MockObject $helpersMock; - protected MockObject $cacheDecoratorMock; protected MockObject $loggerMock; + protected MockObject $responseMock; + protected MockObject $entityStatementMock; protected function setUp(): void { - $this->httpClientDecoratorMock = $this->createMock(HttpClientDecorator::class); $this->entityStatementFactoryMock = $this->createMock(EntityStatementFactory::class); + $this->artifactFetcherMock = $this->createMock(ArtifactFetcher::class); $this->maxCacheDurationMock = $this->createMock(DateIntervalDecorator::class); $this->helpersMock = $this->createMock(Helpers::class); - $this->cacheDecoratorMock = $this->createMock(CacheDecorator::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); } protected function sut( - ?HttpClientDecorator $httpClientDecorator = null, ?EntityStatementFactory $entityStatementFactory = null, + ?ArtifactFetcher $artifactFetcher = null, ?DateIntervalDecorator $maxCacheDuration = null, ?Helpers $helpers = null, - ?CacheDecorator $cacheDecorator = null, ?LoggerInterface $logger = null, ): EntityStatementFetcher { - $httpClientDecorator ??= $this->httpClientDecoratorMock; $entityStatementFactory ??= $this->entityStatementFactoryMock; + $artifactFetcher ??= $this->artifactFetcherMock; $maxCacheDuration ??= $this->maxCacheDurationMock; $helpers ??= $this->helpersMock; - $cacheDecorator ??= $this->cacheDecoratorMock; $logger ??= $this->loggerMock; return new EntityStatementFetcher( - $httpClientDecorator, $entityStatementFactory, + $artifactFetcher, $maxCacheDuration, $helpers, - $cacheDecorator, $logger, ); } @@ -66,20 +77,68 @@ public function testCanCreateInstance(): void $this->assertInstanceOf(EntityStatementFetcher::class, $this->sut()); } - public function testCanFetchFromCache(): void + public function testHasRightExpectedContentTypeHttpHeader(): void + { + $this->assertSame( + ContentTypesEnum::ApplicationEntityStatementJwt->value, + $this->sut()->getExpectedContentTypeHttpHeader(), + ); + } + + public function testCanFetchFromCacheOrWellKnownEndpoint(): void + { + $this->artifactFetcherMock->expects($this->once())->method('fromCacheAsString') + ->willReturn(null); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->responseMock->method('getHeaderLine') + ->willReturn(ContentTypesEnum::ApplicationEntityStatementJwt->value); + + $this->artifactFetcherMock->expects($this->once())->method('fromNetwork') + ->willReturn($this->responseMock); + + $this->assertInstanceOf( + EntityStatement::class, + $this->sut()->fromCacheOrWellKnownEndpoint('entityId'), + ); + } + + public function testCanFetchFromCacheOrFetchEndpoint(): void { - $this->cacheDecoratorMock->expects($this->once())->method('get') - ->with(null, 'uri') + $this->entityStatementMock->expects($this->once()) + ->method('getFederationFetchEndpoint') + ->willReturn('fetch-uri'); + + $this->artifactFetcherMock->expects($this->once())->method('fromCacheAsString') ->willReturn('token'); $this->entityStatementFactoryMock->expects($this->once())->method('fromToken') ->with('token'); - $this->assertInstanceOf(EntityStatement::class, $this->sut()->fromCache('uri')); + $this->sut()->fromCacheOrFetchEndpoint('entityId', $this->entityStatementMock); + } + + public function testFetchFromCacheOrFetchEndpointThrowsIfNoFetchEndpoint(): void + { + $this->entityStatementMock->expects($this->once()) + ->method('getFederationFetchEndpoint') + ->willReturn(null); + + $this->expectException(EntityStatementException::class); + $this->expectExceptionMessage('fetch'); + + $this->sut()->fromCacheOrFetchEndpoint('entityId', $this->entityStatementMock); } - public function testFetchFromCacheReturnsNull(): void + public function testCanFetchFromCache(): void { - $this->markTestIncomplete('TODO mivanci'); + $this->artifactFetcherMock->expects($this->once())->method('fromCacheAsString') + ->with('uri') + ->willReturn('token'); + + $this->entityStatementFactoryMock->expects($this->once())->method('fromToken') + ->with('token'); + + $this->assertInstanceOf(EntityStatement::class, $this->sut()->fromCache('uri')); } } diff --git a/tests/src/Federation/EntityStatementTest.php b/tests/src/Federation/EntityStatementTest.php index 1c14bfc..00f1043 100644 --- a/tests/src/Federation/EntityStatementTest.php +++ b/tests/src/Federation/EntityStatementTest.php @@ -286,4 +286,105 @@ public function testThrowsOnInvalidTypeHeader(): void $this->sut(); } + + public function testCanGetFederationFetchEndpoint(): void + { + $payload = $this->validPayload; + $payload['metadata']['federation_entity']['federation_fetch_endpoint'] = 'uri'; + + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertSame('uri', $this->sut()->getFederationFetchEndpoint()); + } + + public function testFederationFetchEndpointIsOptional(): void + { + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($this->validPayload); + + $this->assertNull($this->sut()->getFederationFetchEndpoint()); + } + + public function testMetadataIsOptional(): void + { + $payload = $this->validPayload; + unset($payload['metadata']); + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertNull($this->sut()->getMetadata()); + } + + public function testThrowsForInvalidMetadataClaim(): void + { + $payload = $this->validPayload; + $payload['metadata'] = 'invalid'; + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Metadata'); + + $this->sut()->getMetadata(); + } + + public function testCanGetMetadataPolicyClaim(): void + { + $payload = $this->validPayload; + $payload['sub'] = 'something-else'; + unset($payload['authority_hints']); + $payload['metadata_policy'] = [ + 'openid_relying_party' => [ + 'contacts' => [ + 'add' => ['helpdesk@subordinate.org'], + ], + ], + ]; + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->assertSame($payload['metadata_policy'], $this->sut()->getMetadataPolicy()); + } + + public function testThrowsForInvalidMetadataPolicyClaim(): void + { + $payload = $this->validPayload; + $payload['sub'] = 'something-else'; + unset($payload['authority_hints']); + $payload['metadata_policy'] = 'invalid'; + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Metadata Policy'); + + $this->sut()->getMetadataPolicy(); + } + + public function testThrowsIfMetadataPolicyIsSetInConfigurationStatement(): void + { + $payload = $this->validPayload; + unset($payload['authority_hints']); + $payload['metadata_policy'] = [ + 'openid_relying_party' => [ + 'contacts' => [ + 'add' => ['helpdesk@subordinate.org'], + ], + ], + ]; + + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + $this->jsonHelperMock->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('configuration'); + + $this->sut()->getMetadataPolicy(); + } } diff --git a/tests/src/Federation/Factories/TrustChainFactoryTest.php b/tests/src/Federation/Factories/TrustChainFactoryTest.php index 66fa9df..6d5c3e1 100644 --- a/tests/src/Federation/Factories/TrustChainFactoryTest.php +++ b/tests/src/Federation/Factories/TrustChainFactoryTest.php @@ -13,9 +13,9 @@ use SimpleSAML\OpenID\Federation\EntityStatement; use SimpleSAML\OpenID\Federation\Factories\EntityStatementFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory; +use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\TrustChain; -use SimpleSAML\OpenID\Helpers; #[CoversClass(TrustChainFactory::class)] #[UsesClass(TrustChain::class)] @@ -23,33 +23,33 @@ class TrustChainFactoryTest extends TestCase { protected MockObject $entityStatementFactoryMock; protected MockObject $timestampValidationLeewayMock; - protected MockObject $helpersMock; protected MockObject $metadataPolicyResolverMock; + protected MockObject $metadataPolicyApplicatorMock; protected function setUp(): void { $this->entityStatementFactoryMock = $this->createMock(EntityStatementFactory::class); $this->timestampValidationLeewayMock = $this->createMock(DateIntervalDecorator::class); - $this->helpersMock = $this->createMock(Helpers::class); $this->metadataPolicyResolverMock = $this->createMock(MetadataPolicyResolver::class); + $this->metadataPolicyApplicatorMock = $this->createMock(MetadataPolicyApplicator::class); } protected function sut( ?EntityStatementFactory $entityStatementFactory = null, ?DateIntervalDecorator $timestampValidationLeewayMock = null, - ?Helpers $helpers = null, ?MetadataPolicyResolver $metadataPolicyResolver = null, + ?MetadataPolicyApplicator $metadataPolicyApplicator = null, ): TrustChainFactory { $entityStatementFactory ??= $this->entityStatementFactoryMock; $timestampValidationLeewayMock ??= $this->timestampValidationLeewayMock; - $helpers ??= $this->helpersMock; $metadataPolicyResolver ??= $this->metadataPolicyResolverMock; + $metadataPolicyApplicator ??= $this->metadataPolicyApplicatorMock; return new TrustChainFactory( $entityStatementFactory, $timestampValidationLeewayMock, - $helpers, $metadataPolicyResolver, + $metadataPolicyApplicator, ); } diff --git a/tests/src/Federation/MetadataPolicyApplicatorTest.php b/tests/src/Federation/MetadataPolicyApplicatorTest.php new file mode 100644 index 0000000..d0ab254 --- /dev/null +++ b/tests/src/Federation/MetadataPolicyApplicatorTest.php @@ -0,0 +1,422 @@ + [ + 'default' => [ + 0 => 'authorization_code', + ], + 'superset_of' => [ + 0 => 'authorization_code', + ], + 'subset_of' => [ + 0 => 'authorization_code', + ], + ], + 'token_endpoint_auth_method' => [ + 'one_of' => [ + 0 => 'self_signed_tls_client_auth', + ], + 'essential' => true, + ], + 'token_endpoint_auth_signing_alg' => [ + 'one_of' => [ + 0 => 'PS256', + 1 => 'ES256', + ], + ], + 'subject_type' => [ + 'value' => 'pairwise', + ], + 'contacts' => [ + 'add' => [ + 0 => 'helpdesk@federation.example.org', + 1 => 'helpdesk@org.example.org', + ], + ], + ]; + protected array $metadataSample = [ + 'redirect_uris' => [ + 0 => 'https://rp.example.org/callback', + ], + 'response_types' => [ + 0 => 'code', + ], + 'token_endpoint_auth_method' => 'self_signed_tls_client_auth', + 'sector_identifier_uri' => 'https://org.example.org/sector-ids.json', + 'policy_uri' => 'https://org.example.org/policy.html', + 'contacts' => [ + 0 => 'rp_admins@rp.example.org', + ], + ]; + + protected function sut( + ?Helpers $helpers = null, + ): MetadataPolicyApplicator { + $helpers ??= $this->helpersMock; + + return new MetadataPolicyApplicator($helpers); + } + + protected function setUp(): void + { + $this->helpersMock = $this->createMock(Helpers::class); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(MetadataPolicyApplicator::class, $this->sut()); + } + + public function testCanApplyBasicMetadataPolicy(): void + { + $expectedResolvedMetadata = [ + 'redirect_uris' => [ + 0 => 'https://rp.example.org/callback', + ], + 'grant_types' => [ + 0 => 'authorization_code', + ], + 'response_types' => [ + 0 => 'code', + ], + 'token_endpoint_auth_method' => 'self_signed_tls_client_auth', + 'subject_type' => 'pairwise', + 'sector_identifier_uri' => 'https://org.example.org/sector-ids.json', + 'policy_uri' => 'https://org.example.org/policy.html', + 'contacts' => [ + 0 => 'rp_admins@rp.example.org', + 1 => 'helpdesk@federation.example.org', + 2 => 'helpdesk@org.example.org', + ], + ]; + + $resolvedMetadata = $this->sut()->for( + $this->metadataPolicySample, + $this->metadataSample, + ); + + $this->assertEquals($expectedResolvedMetadata, $resolvedMetadata); + } + + public function testCanHandleScopeClaims(): void + { + $metadataPolicy = [ + 'scope' => [ + 'superset_of' => [ + 0 => 'openid', + ], + 'add' => [ + 0 => 'email', + ], + ], + ]; + + $metadata = [ + 'scope' => 'openid profile', + ]; + + $this->assertEquals( + ['scope' => 'openid profile email'], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanUnsetMetadataValue(): void + { + $metadataPolicy = [ + 'scope' => [ + 'value' => null, + ], + ]; + + $metadata = [ + 'scope' => 'openid profile', + ]; + + $this->assertEquals( + [], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanAddNonExistingMetadataValue(): void + { + $metadataPolicy = [ + 'scope' => [ + 'add' => 'openid', + ], + ]; + + $metadata = []; + + $this->assertEquals( + ['scope' => 'openid'], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testRemovesParameterOnEmptySubsetOf(): void + { + $metadataPolicy = [ + 'scope' => [ + 'subset_of' => ['openid'], + ], + ]; + $metadata = [ + 'scope' => 'profile', + ]; + + $this->assertEquals( + [], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testHasEmptyParameterOnNonExistingParameterForSubsetOf(): void + { + $metadataPolicy = [ + 'scope' => [ + 'subset_of' => ['openid'], + ], + ]; + $metadata = []; + + $this->assertEquals( + [], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testHasEmptyParameterOnNonExistingParameterForSupersetOf(): void + { + $metadataPolicy = [ + 'scope' => [ + 'superset_of' => ['openid'], + ], + ]; + $metadata = []; + + $this->assertEquals( + [], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testHasEmptyParameterOnNonEssentialParameter(): void + { + $metadataPolicy = [ + 'scope' => [ + 'essential' => false, + ], + ]; + $metadata = []; + + $this->assertEquals( + [], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleValueRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'value' => '1', + ], + ]; + $metadata = [ + 'something' => '2', + ]; + + $this->assertEquals( + ['something' => '1'], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleAddRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'add' => ['2'], + ], + ]; + $metadata = [ + 'something' => ['1'], + ]; + + $this->assertEquals( + ['something' => ['1', '2']], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleDefaultRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'default' => '1', + ], + ]; + $metadata = []; + + $this->assertEquals( + ['something' => '1'], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleOneOfRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'one_of' => ['1', '2'], + ], + ]; + $metadata = [ + 'something' => '1', + ]; + + $this->assertEquals( + ['something' => '1'], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleOneOfBreakRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'one_of' => ['1'], + ], + ]; + $metadata = [ + 'something' => '2', + ]; + + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('one of'); + + $this->sut()->for($metadataPolicy, $metadata); + } + + public function testCanHandleSubsetOfRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'subset_of' => ['1', '2'], + ], + ]; + $metadata = [ + 'something' => ['1'], + ]; + + $this->assertEquals( + ['something' => ['1']], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleSubsetOfBreakRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'subset_of' => ['1'], + ], + ]; + $metadata = [ + 'something' => ['1', '2'], + ]; + + $this->assertEquals( + ['something' => ['1']], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleSupersetOfRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'superset_of' => ['1'], + ], + ]; + $metadata = [ + 'something' => ['1', '2'], + ]; + + $this->assertEquals( + ['something' => ['1', '2']], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleSupersetOfBreakRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'superset_of' => ['1', '2'], + ], + ]; + $metadata = [ + 'something' => ['1'], + ]; + + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('superset of'); + + $this->sut()->for($metadataPolicy, $metadata); + } + + public function testCanHandleEssentialRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'essential' => true, + ], + ]; + $metadata = [ + 'something' => ['1', '2'], + ]; + + $this->assertEquals( + ['something' => ['1', '2']], + $this->sut()->for($metadataPolicy, $metadata), + ); + } + + public function testCanHandleEssentialBreakRule(): void + { + $metadataPolicy = [ + 'something' => [ + 'essential' => true, + ], + ]; + $metadata = []; + + + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('essential'); + + $this->sut()->for($metadataPolicy, $metadata); + } +} diff --git a/tests/src/Federation/MetadataPolicyResolverTest.php b/tests/src/Federation/MetadataPolicyResolverTest.php new file mode 100644 index 0000000..55e6696 --- /dev/null +++ b/tests/src/Federation/MetadataPolicyResolverTest.php @@ -0,0 +1,247 @@ + [ + 'grant_types' => [ + 'default' => ['authorization_code',], + 'subset_of' => ['authorization_code', 'refresh_token',], + 'superset_of' => ['authorization_code'], + ], + 'token_endpoint_auth_method' => [ + 'one_of' => ['private_key_jwt', 'self_signed_tls_client_auth',], + 'essential' => true, + ], + 'token_endpoint_auth_signing_alg' => [ + 'one_of' => ['PS256', 'ES256',], + ], + 'subject_type' => [ + 'value' => 'pairwise', + ], + 'contacts' => [ + 'add' => ['helpdesk@federation.example.org'], + ], + ], + ]; + + protected array $intermediateMetadataPolicySample = [ + 'openid_relying_party' => [ + 'grant_types' => [ + 'subset_of' => ['authorization_code',], + ], + 'token_endpoint_auth_method' => [ + 'one_of' => ['self_signed_tls_client_auth'], + ], + 'contacts' => [ + 'add' => ['helpdesk@org.example.org'], + ], + 'subject_type' => [ + 'value' => 'pairwise', + ], + ], + ]; + + protected function setUp(): void + { + $this->helpersMock = $this->createMock(Helpers::class); + } + + protected function sut( + ?Helpers $helpers = null, + ): MetadataPolicyResolver { + $helpers ??= $this->helpersMock; + + return new MetadataPolicyResolver($helpers); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(MetadataPolicyResolver::class, $this->sut()); + } + + public function testForHappyFlow(): void + { + $metadataPolicy = $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $this->trustAnchorMetadataPolicySample, + $this->intermediateMetadataPolicySample, + ], + ); + + $this->assertIsArray($metadataPolicy); + } + + public function testReturnsEmptyArrayIfEntityTypeNotPresent(): void + { + $this->assertEmpty( + $this->sut()->for( + EntityTypesEnum::FederationEntity, + [ + $this->trustAnchorMetadataPolicySample, + $this->intermediateMetadataPolicySample, + ], + ), + ); + } + + public function testThrowsInCaseOfDifferentValueOperatorValue(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('Different'); + + $intermediateMetadataPolicy = $this->intermediateMetadataPolicySample; + $intermediateMetadataPolicy['openid_relying_party']['subject_type']['value'] = 'different'; + + $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $this->trustAnchorMetadataPolicySample, + $intermediateMetadataPolicy, + ], + ); + } + + public function testThrowsForInvalidEssentialOperatorValueChange(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('Invalid'); + + $intermediateMetadataPolicy = $this->intermediateMetadataPolicySample; + $intermediateMetadataPolicy['openid_relying_party']['token_endpoint_auth_method']['essential'] = false; + + $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $this->trustAnchorMetadataPolicySample, + $intermediateMetadataPolicy, + ], + ); + } + + public function testSetsEssentialOperatorValueInCaseOfCurrentFalseValue(): void + { + $trustAnchorMetadataPolicy = $this->trustAnchorMetadataPolicySample; + $trustAnchorMetadataPolicy['openid_relying_party']['token_endpoint_auth_method']['essential'] = false; + + $intermediateMetadataPolicy = $this->intermediateMetadataPolicySample; + $intermediateMetadataPolicy['openid_relying_party']['token_endpoint_auth_method']['essential'] = true; + + $metadataPolicy = $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $trustAnchorMetadataPolicy, + $intermediateMetadataPolicy, + ], + ); + + $this->assertTrue($metadataPolicy['token_endpoint_auth_method']['essential']); + } + + public function testThrowsForUnsupportedCriticalOperator(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('critical'); + + $trustAnchorMetadataPolicy = $this->trustAnchorMetadataPolicySample; + $trustAnchorMetadataPolicy['openid_relying_party']['some_parameter']['critical_operator'] = true; + + + $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $trustAnchorMetadataPolicy, + ], + ['critical_operator'], + ); + } + + public function testThrowsForEmptyIntersectionForSubsetOf(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('intersection'); + + $intermediateMetadataPolicy = $this->intermediateMetadataPolicySample; + $intermediateMetadataPolicy['openid_relying_party']['grant_types']['subset_of'] = ['invalid']; + + $this->sut()->for( + EntityTypesEnum::OpenIdRelyingParty, + [ + $this->trustAnchorMetadataPolicySample, + $intermediateMetadataPolicy, + ], + ); + } + + public function testCanEnsureFormat(): void + { + $this->assertSame( + $this->intermediateMetadataPolicySample, + $this->sut()->ensureFormat($this->intermediateMetadataPolicySample), + ); + } + + public function testEnsureFormatThrowsOnNonStringForEntityTypeKey(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('entity type'); + + $policy = ['a']; + $this->sut()->ensureFormat($policy); + } + + public function testEnsureFormatThrowsOnNonSArrayForEntityTypeValue(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('entity type'); + + $policy = ['a' => 'b']; + $this->sut()->ensureFormat($policy); + } + + public function testEnsureFormatThrowsOnNonStringForParameterKey(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('parameter'); + + $policy = ['a' => ['b']]; + $this->sut()->ensureFormat($policy); + } + + public function testEnsureFormatThrowsOnNonStringForParameterValue(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('parameter'); + + $policy = ['a' => ['b' => 'c']]; + $this->sut()->ensureFormat($policy); + } + + public function testEnsureFormatThrowsOnNonStringForOperatorKey(): void + { + $this->expectException(MetadataPolicyException::class); + $this->expectExceptionMessage('operator'); + + $policy = ['a' => ['b' => ['c']]]; + $this->sut()->ensureFormat($policy); + } +} diff --git a/tests/src/Federation/TrustChainBagTest.php b/tests/src/Federation/TrustChainBagTest.php index 956285f..2db5bb8 100644 --- a/tests/src/Federation/TrustChainBagTest.php +++ b/tests/src/Federation/TrustChainBagTest.php @@ -98,4 +98,9 @@ public function testCanGetShortestByTrustAnchorPriority(): void // Returns null if Trust Anchor is unknown. $this->assertNull($sut->getShortestByTrustAnchorPriority('unknown')); } + + public function testCanGetCount(): void + { + $this->assertSame(1, $this->sut()->getCount()); + } } diff --git a/tests/src/Federation/TrustChainResolverTest.php b/tests/src/Federation/TrustChainResolverTest.php index 59fee27..a366a56 100644 --- a/tests/src/Federation/TrustChainResolverTest.php +++ b/tests/src/Federation/TrustChainResolverTest.php @@ -97,9 +97,10 @@ public function testCanGetConfigurationChains(): void $this->entityStatementFetcherMock ->expects($this->exactly(3)) ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback( + fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'), + ); $this->leafEntityConfigurationMock ->expects($this->once()) @@ -136,9 +137,8 @@ public function testCanLimitMaximumConfigurationChainDepth(): void $this->entityStatementFetcherMock ->expects($this->exactly(2)) ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->leafEntityConfigurationMock ->method('getAuthorityHints') @@ -162,9 +162,8 @@ public function testCanDetectLoopInConfigurationChains(): void { $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->leafEntityConfigurationMock ->method('getAuthorityHints') @@ -192,9 +191,8 @@ public function testCanBailOnMaxAuthorityHintsRule(): void $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->loggerMock ->expects($this->atLeastOnce()) @@ -212,9 +210,8 @@ public function testCanResolveTrustChain(): void { $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->leafEntityConfigurationMock ->expects($this->once()) @@ -238,9 +235,8 @@ public function testCanResolveMultipleTrustChains(): void { $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->leafEntityConfigurationMock ->expects($this->once()) @@ -299,9 +295,8 @@ public function testCanWarnOnTrustChainResolutionSubordinateStatementFetchError( { $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->entityStatementFetcherMock ->method('fromCacheOrFetchEndpoint') @@ -329,9 +324,8 @@ public function testTrustChainResolveThrowsOnTrustChainBagFactoryError(): void { $this->entityStatementFetcherMock ->method('fromCacheOrWellKnownEndpoint') - ->willReturnCallback(function (string $entityId) { - return $this->configChainSample[$entityId] ?? throw new \Exception('No entity.'); - }); + ->willReturnCallback(fn(string $entityId) => + $this->configChainSample[$entityId] ?? throw new \Exception('No entity.')); $this->leafEntityConfigurationMock ->expects($this->once()) diff --git a/tests/src/Federation/TrustChainTest.php b/tests/src/Federation/TrustChainTest.php new file mode 100644 index 0000000..14f5eb5 --- /dev/null +++ b/tests/src/Federation/TrustChainTest.php @@ -0,0 +1,288 @@ +timestampValidationLeewayDecoratorMock = $this->createMock(DateIntervalDecorator::class); + $this->metadataPolicyResolverMock = $this->createMock(MetadataPolicyResolver::class); + $this->metadataPolicyApplicatorMock = $this->createMock(MetadataPolicyApplicator::class); + + $this->expirationTime = time() + 60; + $this->leafMock = $this->createMock(EntityStatement::class); + $this->leafMock->method('isConfiguration')->willReturn(true); + $this->leafMock->method('getExpirationTime')->willReturn($this->expirationTime); + + $this->subordinateMock = $this->createMock(EntityStatement::class); + $this->subordinateMock->method('isConfiguration')->willReturn(false); + $this->subordinateMock->method('getExpirationTime')->willReturn($this->expirationTime); + + $this->trustAnchorMock = $this->createMock(EntityStatement::class); + $this->trustAnchorMock->method('isConfiguration')->willReturn(true); + $this->trustAnchorMock->method('getExpirationTime')->willReturn($this->expirationTime); + } + + protected function sut( + ?DateIntervalDecorator $timestampValidationLeewayDecorator = null, + ?MetadataPolicyResolver $metadataPolicyResolver = null, + ?MetadataPolicyApplicator $metadataPolicyApplicator = null, + ): TrustChain { + $timestampValidationLeewayDecorator ??= $this->timestampValidationLeewayDecoratorMock; + $metadataPolicyResolver ??= $this->metadataPolicyResolverMock; + $metadataPolicyApplicator ??= $this->metadataPolicyApplicatorMock; + + return new TrustChain( + $timestampValidationLeewayDecorator, + $metadataPolicyResolver, + $metadataPolicyApplicator, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(TrustChain::class, $this->sut()); + } + + public function testCanCheckIfEmpty(): void + { + $this->assertTrue($this->sut()->isEmpty()); + $this->assertEmpty($this->sut()->getEntities()); + } + + public function testCanCreateBasicTrustChain(): void + { + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->subordinateMock); + $sut->addTrustAnchor($this->trustAnchorMock); + + $this->assertFalse($sut->isEmpty()); + $this->assertCount(3, $sut->getEntities()); + $this->assertSame(3, $sut->getResolvedLength()); + $this->assertIsArray($sut->jsonSerialize()); + $this->assertSame($this->expirationTime, $sut->getResolvedExpirationTime()); + $this->assertSame($this->leafMock, $sut->getResolvedLeaf()); + $this->assertSame($this->subordinateMock, $sut->getResolvedImmediateSuperior()); + $this->assertSame($this->trustAnchorMock, $sut->getResolvedTrustAnchor()); + $this->assertNull($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + } + + public function testThrowsForNonConfigurationStatementForLeaf(): void + { + $this->expectException(EntityStatementException::class); + $this->expectExceptionMessage('Configuration'); + + $this->sut()->addLeaf($this->subordinateMock); + } + + public function testThrowsForConfigurationStatementForSubordinate(): void + { + $this->expectException(EntityStatementException::class); + $this->expectExceptionMessage('Subordinate'); + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->leafMock); + } + + public function testThrowsForInvalidSubordinateSubject(): void + { + $this->expectException(EntityStatementException::class); + $this->expectExceptionMessage('Subordinate'); + + $this->subordinateMock->method('getSubject')->willReturn('something-different'); + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->subordinateMock); + } + + public function testCanValidateExpirationTimeOnEmptyTrustChain(): void + { + $this->sut()->validateExpirationTime(); + $this->addToAssertionCount(1); + } + + public function testThrowsForInvalidExpirationTime(): void + { + $leafMock = $this->createMock(EntityStatement::class); + $leafMock->method('isConfiguration')->willReturn(true); + $leafMock->method('getExpirationTime')->willReturn(time() - 60); + + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('expiration'); + + $sut = $this->sut(); + $sut->addLeaf($leafMock); + } + + public function testThrowsForNonResolvedState(): void + { + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('resolved'); + + $this->sut()->getResolvedLength(); + } + + public function testThrowsForResolvedState(): void + { + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->subordinateMock); + $sut->addTrustAnchor($this->trustAnchorMock); + + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('resolved'); + + $sut->addTrustAnchor($this->trustAnchorMock); + } + + public function testCanGetResolvedMetadata(): void + { + $leafMetadata = [ + 'openid_relying_party' => [ + 'contacts' => [ + 'helpdesk@leaf.org', + ], + ], + ]; + $this->leafMock->expects($this->once())->method('getMetadata') + ->willReturn($leafMetadata); + + $subordinateMetadata = [ + 'openid_relying_party' => [ + 'some_claim' => 'something', + ], + ]; + $subordinateMetadataPolicy = [ + 'openid_relying_party' => [ + 'contacts' => [ + 'add' => ['helpdesk@subordinate.org'], + ], + ], + ]; + $this->subordinateMock->expects($this->once())->method('getMetadata') + ->willReturn($subordinateMetadata); + $this->subordinateMock->expects($this->once())->method('getMetadataPolicy') + ->willReturn($subordinateMetadataPolicy); + + $this->metadataPolicyResolverMock->expects($this->once())->method('ensureFormat') + ->with($subordinateMetadataPolicy) + ->willReturn($subordinateMetadataPolicy); + + $this->metadataPolicyResolverMock->expects($this->once())->method('for') + ->with( + EntityTypesEnum::OpenIdRelyingParty, + [$subordinateMetadataPolicy], + [], + )->willReturn($subordinateMetadataPolicy); + + $this->metadataPolicyApplicatorMock->expects($this->once())->method('for') + ->with( + $subordinateMetadataPolicy, + [ + 'contacts' => [ + 'helpdesk@leaf.org', + ], + 'some_claim' => 'something', + ], + ); // I don't return value, as it is not important to check it here... + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->subordinateMock); + $sut->addTrustAnchor($this->trustAnchorMock); + + // I'm only interested if all the calls are made as intended. + $this->assertIsArray($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + // Validate that we only resolve metadata once. + $this->assertIsArray($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + } + + public function testCanGetResolvedMetadataIfNoPoliciesAreDefined(): void + { + $leafMetadata = [ + 'openid_relying_party' => [ + 'contacts' => [ + 'helpdesk@leaf.org', + ], + ], + ]; + $this->leafMock->expects($this->once())->method('getMetadata') + ->willReturn($leafMetadata); + + + $this->subordinateMock->expects($this->once())->method('getMetadata') + ->willReturn(null); + $this->subordinateMock->expects($this->once())->method('getMetadataPolicy') + ->willReturn(null); + + $this->metadataPolicyApplicatorMock->expects($this->never())->method('for'); + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addSubordinate($this->subordinateMock); + $sut->addTrustAnchor($this->trustAnchorMock); + + $this->assertIsArray($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + // Validate that we only resolve metadata once. + $this->assertIsArray($sut->getResolvedMetadata(EntityTypesEnum::OpenIdRelyingParty)); + } + + public function testThrowsOnAttemtpToAddMultipleLeafs(): void + { + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('empty'); + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addLeaf($this->leafMock); + } + + public function testThrowsOnAttemtpToAddSubodrinateWithoutLeaf(): void + { + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('non-empty'); + + $sut = $this->sut(); + $sut->addSubordinate($this->subordinateMock); + } + + public function testThrowsOnAttemtpToAddTrustAnchorWithoutSubordinate(): void + { + $this->expectException(TrustChainException::class); + $this->expectExceptionMessage('at least'); + + $sut = $this->sut(); + $sut->addLeaf($this->leafMock); + $sut->addTrustAnchor($this->trustAnchorMock); + } +} diff --git a/tests/src/FederationTest.php b/tests/src/FederationTest.php index 0f391e3..aa57d5c 100644 --- a/tests/src/FederationTest.php +++ b/tests/src/FederationTest.php @@ -27,19 +27,24 @@ use SimpleSAML\OpenID\Federation\Factories\RequestObjectFactory; use SimpleSAML\OpenID\Federation\Factories\TrustChainFactory; use SimpleSAML\OpenID\Federation\Factories\TrustMarkFactory; +use SimpleSAML\OpenID\Federation\MetadataPolicyApplicator; use SimpleSAML\OpenID\Federation\MetadataPolicyResolver; use SimpleSAML\OpenID\Federation\TrustChainResolver; +use SimpleSAML\OpenID\Jws\AbstractJwsFetcher; use SimpleSAML\OpenID\Jws\Factories\JwsParserFactory; use SimpleSAML\OpenID\Jws\Factories\JwsVerifierFactory; use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; +use SimpleSAML\OpenID\Jws\JwsFetcher; use SimpleSAML\OpenID\Jws\JwsParser; use SimpleSAML\OpenID\SupportedAlgorithms; use SimpleSAML\OpenID\SupportedSerializers; +use SimpleSAML\OpenID\Utils\ArtifactFetcher; #[CoversClass(Federation::class)] #[UsesClass(ParsedJwsFactory::class)] #[UsesClass(EntityStatementFetcher::class)] #[UsesClass(MetadataPolicyResolver::class)] +#[UsesClass(MetadataPolicyApplicator::class)] #[UsesClass(TrustChainFactory::class)] #[UsesClass(TrustChainResolver::class)] #[UsesClass(EntityStatementFactory::class)] @@ -57,6 +62,9 @@ #[UsesClass(CacheDecoratorFactory::class)] #[UsesClass(HttpClientDecorator::class)] #[UsesClass(HttpClientDecoratorFactory::class)] +#[UsesClass(ArtifactFetcher::class)] +#[UsesClass(AbstractJwsFetcher::class)] +#[UsesClass(JwsFetcher::class)] class FederationTest extends TestCase { protected MockObject $supportedAlgorithmsMock; diff --git a/tests/src/Helpers/ArrTest.php b/tests/src/Helpers/ArrTest.php index f0be6e6..b751a5b 100644 --- a/tests/src/Helpers/ArrTest.php +++ b/tests/src/Helpers/ArrTest.php @@ -36,4 +36,29 @@ public function testThrowsIfTooDeep(): void $arr = []; $this->sut()->ensureArrayDepth($arr, ...range(0, 100)); } + + public function testCanEnsureStringKeys(): void + { + $this->assertSame( + ['1' => 'a', '2' => 'b'], + $this->sut()->ensureStringKeys([1 => 'a', 2 => 'b']), + ); + $this->assertSame( + ['1' => 1, '2' => 2], + $this->sut()->ensureStringKeys([1 => 1, '2' => 2]), + ); + $this->assertSame( + ['0' => 0, '1' => 1, '2' => 2], + $this->sut()->ensureStringKeys([0, 1, 2]), + ); + + // Test call for nested array + $this->assertSame( + [['0' => 0, '1' => 1], ['0' => 2, '1' => 3]], + array_map( + $this->sut()->ensureStringKeys(...), + [[0, 1], [2, 3]], + ), + ); + } } diff --git a/tests/src/Jwks/Factories/JwksFactoryTest.php b/tests/src/Jwks/Factories/JwksFactoryTest.php index 242a056..d52a6c6 100644 --- a/tests/src/Jwks/Factories/JwksFactoryTest.php +++ b/tests/src/Jwks/Factories/JwksFactoryTest.php @@ -6,9 +6,9 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jwks\Factories\JwksFactory; -use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Jwks\JwksDecorator; #[CoversClass(JwksFactory::class)] diff --git a/tests/src/Jwks/Factories/SignedJwksFactoryTest.php b/tests/src/Jwks/Factories/SignedJwksFactoryTest.php index d97e0e2..2de9f7e 100644 --- a/tests/src/Jwks/Factories/SignedJwksFactoryTest.php +++ b/tests/src/Jwks/Factories/SignedJwksFactoryTest.php @@ -9,11 +9,11 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; use SimpleSAML\OpenID\Helpers; use SimpleSAML\OpenID\Jwks\Factories\JwksFactory; use SimpleSAML\OpenID\Jwks\Factories\SignedJwksFactory; -use PHPUnit\Framework\TestCase; use SimpleSAML\OpenID\Jwks\SignedJwks; use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; use SimpleSAML\OpenID\Jws\JwsDecorator; diff --git a/tests/src/Jwks/JwksDecoratorTest.php b/tests/src/Jwks/JwksDecoratorTest.php index 44f8cad..c50c322 100644 --- a/tests/src/Jwks/JwksDecoratorTest.php +++ b/tests/src/Jwks/JwksDecoratorTest.php @@ -8,9 +8,8 @@ use Jose\Component\Core\JWKSet; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; -use SimpleSAML\OpenID\Jwks; -use SimpleSAML\OpenID\Jwks\JwksDecorator; use PHPUnit\Framework\TestCase; +use SimpleSAML\OpenID\Jwks\JwksDecorator; #[CoversClass(JwksDecorator::class)] class JwksDecoratorTest extends TestCase diff --git a/tests/src/Jwks/JwksFetcherTest.php b/tests/src/Jwks/JwksFetcherTest.php new file mode 100644 index 0000000..85d1133 --- /dev/null +++ b/tests/src/Jwks/JwksFetcherTest.php @@ -0,0 +1,454 @@ + [ + [ + 'alg' => 'RS256', + 'use' => 'sig', + 'kty' => 'RSA', + // phpcs:ignore + 'n' => 'pJgG9F_lwc2cFEC1l6q0fjJYxKPbtVGqJpDggDpDR8MgfbH0jUZP_RvhJGpl_09Bp-PfibLiwxchHZlrCx-fHQyGMaBRivUfq_p12ECEXMaFUcasCP6cyNrDfa5Uchumau4WeC21nYI1NMawiMiWFcHpLCQ7Ul8NMaCM_dkeruhm_xG0ZCqfwu30jOyCsnZdE0izJwPTfBRLpLyivu8eHpwjoIzmwqo8H-ZsbqR0vdRu20-MNS78ppTxwK3QmJhU6VO2r730F6WH9xJd_XUDuVeM4_6Z6WVDXw3kQF-jlpfcssPP303nbqVmfFZSUgS8buToErpMqevMIKREShsjMQ', + 'e' => 'AQAB', + 'kid' => 'F4VFObNusj3PHmrHxpqh4GNiuFHlfh-2s6xMJ95fLYA', + ], + ], + ]; + + protected function setUp(): void + { + $this->httpClientDecoratorMock = $this->createMock(HttpClientDecorator::class); + $this->jwksFactoryMock = $this->createMock(JwksFactory::class); + $this->signedJwksFactoryMock = $this->createMock(SignedJwksFactory::class); + $this->maxCacheDurationDecoratorMock = $this->createMock(DateIntervalDecorator::class); + $this->helpersMock = $this->createMock(Helpers::class); + $this->cacheDecoratorMock = $this->createMock(CacheDecorator::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + + $this->arrHelperMock = $this->createMock(Helpers\Arr::class); + $this->helpersMock->method('arr')->willReturn($this->arrHelperMock); + + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->responseBodyMock = $this->createMock(StreamInterface::class); + $this->responseMock->method('getBody')->willReturn($this->responseBodyMock); + + $this->signedJwksMock = $this->createMock(SignedJwks::class); + } + + protected function sut( + ?HttpClientDecorator $httpClientDecorator = null, + ?JwksFactory $jwksFactory = null, + ?SignedJwksFactory $signedJwksFactory = null, + ?DateIntervalDecorator $maxCacheDurationDecorator = null, + ?Helpers $helpers = null, + ?CacheDecorator $cacheDecorator = null, + ?LoggerInterface $logger = null, + ): JwksFetcher { + $httpClientDecorator ??= $this->httpClientDecoratorMock; + $jwksFactory ??= $this->jwksFactoryMock; + $signedJwksFactory ??= $this->signedJwksFactoryMock; + $maxCacheDurationDecorator ??= $this->maxCacheDurationDecoratorMock; + $helpers ??= $this->helpersMock; + $cacheDecorator ??= $this->cacheDecoratorMock; + $logger ??= $this->loggerMock; + + return new JwksFetcher( + $httpClientDecorator, + $jwksFactory, + $signedJwksFactory, + $maxCacheDurationDecorator, + $helpers, + $cacheDecorator, + $logger, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(JwksFetcher::class, $this->sut()); + } + + public function testCanGetFromCache(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn('jwks-json'); + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn($this->jwksArraySample); + $this->arrHelperMock->expects($this->once())->method('ensureStringKeys') + ->willReturnArgument(0); + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->assertInstanceOf(JwksDecorator::class, $this->sut()->fromCache('uri')); + } + + public function testLogsErrorInCaseOfCacheError(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willThrowException(new \Exception('Error')); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('cache')); + + $this->assertNull($this->sut()->fromCache('uri')); + } + + public function testReturnsNullInCaseOfNonStringValueInCache(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn(123); + + $this->assertNull($this->sut()->fromCache('uri')); + } + + public function testLogsErrorInCaseOfCacheValueDecodeError(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willThrowException(new JsonException('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('decode')); + + $this->assertNull($this->sut()->fromCache('uri')); + } + + public function testLogsErrorInCaseOfNonArrayCacheValue(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn(123); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('type')); + + $this->assertNull($this->sut()->fromCache('uri')); + } + + + public function testLogsErrorInCaseOfInvalidArrayCacheValue(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn(['invalid']); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('format')); + + $this->assertNull($this->sut()->fromCache('uri')); + } + + public function testCanGetFromJwksUri(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn($this->jwksArraySample); + + $this->arrHelperMock->expects($this->once())->method('ensureStringKeys') + ->willReturnArgument(0); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', $this->anything(), 'uri'); + + $this->sut()->fromJwksUri('uri'); + } + + public function testJwksUriReturnsNullOnHttpError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willThrowException(new HttpException('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('URI')); + + $this->assertNull($this->sut()->fromJwksUri('uri')); + } + + public function testJwksUriReturnsNullOnJsonDecodeError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willThrowException(new JsonException('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('decode')); + + $this->assertNull($this->sut()->fromJwksUri('uri')); + } + + public function testJwksUriLogsErrorInCaseOfCacheSetError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn($this->jwksArraySample); + + $this->arrHelperMock->expects($this->once())->method('ensureStringKeys') + ->willReturnArgument(0); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', $this->anything(), 'uri') + ->willThrowException(new \Exception('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('cache')); + + $this->sut()->fromJwksUri('uri'); + } + + public function testCanGetFromCacheOrJwksUri(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn(null); + + $this->responseMock->method('getStatusCode')->willReturn(404); + + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('jwks-json'); + + $this->jsonHelperMock->expects($this->once())->method('decode') + ->with('jwks-json') + ->willReturn($this->jwksArraySample); + + $this->arrHelperMock->expects($this->once())->method('ensureStringKeys') + ->willReturnArgument(0); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->assertInstanceOf(JwksDecorator::class, $this->sut()->fromCacheOrJwksUri('uri')); + } + + public function testCanGetFromSignedJwksUri(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('token'); + + $this->signedJwksMock->expects($this->once())->method('verifyWithKeySet'); + $this->signedJwksMock->method('jsonSerialize')->willReturn($this->jwksArraySample); + + $this->signedJwksFactoryMock->expects($this->once())->method('fromToken') + ->with('token') + ->willReturn($this->signedJwksMock); + + $this->jsonHelperMock->expects($this->once())->method('encode') + ->with($this->jwksArraySample) + ->willReturn('jwks-json'); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', $this->anything(), 'uri'); + + $this->sut()->fromSignedJwksUri('uri', ['not-important-for-sut']); + } + + public function testSignedJwksUriTakesExpClaimIntoAccountForCaching(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('token'); + + $expirationTime = time() + 60; + $this->signedJwksMock->expects($this->once())->method('verifyWithKeySet'); + $this->signedJwksMock->method('jsonSerialize')->willReturn($this->jwksArraySample); + $this->signedJwksMock->expects($this->once())->method('getExpirationTime') + ->willReturn($expirationTime); + + $this->signedJwksFactoryMock->expects($this->once())->method('fromToken') + ->with('token') + ->willReturn($this->signedJwksMock); + + $this->jsonHelperMock->expects($this->once())->method('encode') + ->with($this->jwksArraySample) + ->willReturn('jwks-json'); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->maxCacheDurationDecoratorMock->expects($this->once()) + ->method('lowestInSecondsComparedToExpirationTime') + ->with($expirationTime) + ->willReturn(60); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', 60, 'uri'); + + $this->sut()->fromSignedJwksUri('uri', ['not-important-for-sut']); + } + + public function testSignedJwksUriReturnsNullOnHttpError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willThrowException(new HttpException('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('URI')); + + $this->sut()->fromSignedJwksUri('uri', ['not-important-for-sut']); + } + + public function testSignedJwksUriLogsErrorOnCacheSetError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('token'); + + $this->signedJwksMock->method('jsonSerialize')->willReturn($this->jwksArraySample); + + $this->signedJwksFactoryMock->expects($this->once())->method('fromToken') + ->with('token') + ->willReturn($this->signedJwksMock); + + $this->jsonHelperMock->expects($this->once())->method('encode') + ->with($this->jwksArraySample) + ->willReturn('jwks-json'); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', $this->anything(), 'uri') + ->willThrowException(new \Exception('Error')); + + $this->loggerMock->expects($this->atLeastOnce())->method('error') + ->with($this->stringContains('cache')); + + $this->sut()->fromSignedJwksUri('uri', ['not-important-for-sut']); + } + + public function testCanGetFromCacheOrSignedJwksUri(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'uri') + ->willReturn(null); + + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri') + ->willReturn($this->responseMock); + + $this->responseBodyMock->expects($this->once())->method('getContents') + ->willReturn('token'); + + $this->signedJwksMock->method('jsonSerialize')->willReturn($this->jwksArraySample); + + $this->signedJwksFactoryMock->expects($this->once())->method('fromToken') + ->with('token') + ->willReturn($this->signedJwksMock); + + $this->jsonHelperMock->expects($this->once())->method('encode') + ->with($this->jwksArraySample) + ->willReturn('jwks-json'); + + $this->jwksFactoryMock->expects($this->once())->method('fromKeyData') + ->with($this->jwksArraySample); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('jwks-json', $this->anything(), 'uri'); + + $this->sut()->fromCacheOrSignedJwksUri('uri', ['not-important-for-sut']); + } +} diff --git a/tests/src/JwksTest.php b/tests/src/JwksTest.php index 1294415..3c196a3 100644 --- a/tests/src/JwksTest.php +++ b/tests/src/JwksTest.php @@ -12,12 +12,14 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use SimpleSAML\OpenID\Decorators\CacheDecorator; +use SimpleSAML\OpenID\Decorators\DateIntervalDecorator; +use SimpleSAML\OpenID\Decorators\HttpClientDecorator; use SimpleSAML\OpenID\Factories\AlgorithmManagerFactory; use SimpleSAML\OpenID\Factories\CacheDecoratorFactory; use SimpleSAML\OpenID\Factories\DateIntervalDecoratorFactory; use SimpleSAML\OpenID\Factories\HttpClientDecoratorFactory; use SimpleSAML\OpenID\Factories\JwsSerializerManagerFactory; -use SimpleSAML\OpenID\Helpers; use SimpleSAML\OpenID\Jwks; use SimpleSAML\OpenID\Jwks\Factories\JwksFactory; use SimpleSAML\OpenID\Jwks\Factories\SignedJwksFactory; @@ -25,6 +27,7 @@ use SimpleSAML\OpenID\Jws\Factories\JwsParserFactory; use SimpleSAML\OpenID\Jws\Factories\JwsVerifierFactory; use SimpleSAML\OpenID\Jws\Factories\ParsedJwsFactory; +use SimpleSAML\OpenID\Jws\JwsParser; use SimpleSAML\OpenID\SupportedAlgorithms; use SimpleSAML\OpenID\SupportedSerializers; @@ -33,91 +36,63 @@ #[UsesClass(ParsedJwsFactory::class)] #[UsesClass(SignedJwksFactory::class)] #[UsesClass(JwksFetcher::class)] +#[UsesClass(CacheDecorator::class)] +#[UsesClass(DateIntervalDecorator::class)] +#[UsesClass(HttpClientDecorator::class)] +#[UsesClass(CacheDecoratorFactory::class)] +#[UsesClass(DateIntervalDecoratorFactory::class)] +#[UsesClass(HttpClientDecoratorFactory::class)] +#[UsesClass(AlgorithmManagerFactory::class)] +#[UsesClass(JwsSerializerManagerFactory::class)] +#[UsesClass(JwsParserFactory::class)] +#[UsesClass(JwsVerifierFactory::class)] +#[UsesClass(JwsParser::class)] class JwksTest extends TestCase { protected MockObject $supportedAlgorithmsMock; protected MockObject $supportedSerializersMock; - protected MockObject $maxCacheDurationMock; + protected DateInterval $maxCacheDuration; protected MockObject $cacheMock; protected MockObject $httpClientMock; protected MockObject $loggerMock; - protected MockObject $helpersMock; - protected MockObject $algorithmManagerFactoryMock; - protected MockObject $jwsSerializerManagerFactoryMock; - protected MockObject $jwsParserFactoryMock; - protected MockObject $jwsVerifierFactoryMock; - protected MockObject $timestampValidationLeewayMock; - protected MockObject $dateIntervalDecoratorFactoryMock; - protected MockObject $cacheDecoratorFactoryMock; - protected MockObject $httpClientDecoratorFactoryMock; + protected DateInterval $timestampValidationLeeway; + protected function setUp(): void { $this->supportedAlgorithmsMock = $this->createMock(SupportedAlgorithms::class); $this->supportedSerializersMock = $this->createMock(SupportedSerializers::class); - $this->maxCacheDurationMock = $this->createMock(DateInterval::class); + $this->maxCacheDuration = new DateInterval('PT1M'); + $this->timestampValidationLeeway = new DateInterval('PT1M'); $this->cacheMock = $this->createMock(CacheInterface::class); - $this->httpClientMock = $this->createMock(Client::class); $this->loggerMock = $this->createMock(LoggerInterface::class); - $this->helpersMock = $this->createMock(Helpers::class); - $this->algorithmManagerFactoryMock = $this->createMock(AlgorithmManagerFactory::class); - $this->jwsSerializerManagerFactoryMock = $this->createMock(JwsSerializerManagerFactory::class); - $this->jwsParserFactoryMock = $this->createMock(JwsParserFactory::class); - $this->jwsVerifierFactoryMock = $this->createMock(JwsVerifierFactory::class); - $this->timestampValidationLeewayMock = $this->createMock(DateInterval::class); - $this->dateIntervalDecoratorFactoryMock = $this->createMock(DateIntervalDecoratorFactory::class); - $this->cacheDecoratorFactoryMock = $this->createMock(CacheDecoratorFactory::class); - $this->httpClientDecoratorFactoryMock = $this->createMock(HttpClientDecoratorFactory::class); + $this->httpClientMock = $this->createMock(Client::class); } protected function sut( ?SupportedAlgorithms $supportedAlgorithms = null, ?SupportedSerializers $supportedSerializers = null, ?DateInterval $maxCacheDuration = null, + ?DateInterval $timestampValidationLeeway = null, ?CacheInterface $cache = null, - ?Client $httpClient = null, ?LoggerInterface $logger = null, - ?Helpers $helpers = null, - ?AlgorithmManagerFactory $algorithmManagerFactory = null, - ?JwsSerializerManagerFactory $jwsSerializerManagerFactory = null, - ?JwsParserFactory $jwsParserFactory = null, - ?JwsVerifierFactory $jwsVerifierFactory = null, - ?DateInterval $timestampValidationLeeway = null, - ?DateIntervalDecoratorFactory $dateIntervalDecoratorFactory = null, - ?CacheDecoratorFactory $cacheDecoratorFactory = null, - ?HttpClientDecoratorFactory $httpClientDecoratorFactory = null, + ?Client $httpClient = null, ): Jwks { $supportedAlgorithms ??= $this->supportedAlgorithmsMock; $supportedSerializers ??= $this->supportedSerializersMock; - $maxCacheDuration ??= $this->maxCacheDurationMock; + $maxCacheDuration ??= $this->maxCacheDuration; + $timestampValidationLeeway ??= $this->timestampValidationLeeway; $cache ??= $this->cacheMock; - $httpClient ??= $this->httpClientMock; $logger ??= $this->loggerMock; - $helpers ??= $this->helpersMock; - $algorithmManagerFactory ??= $this->algorithmManagerFactoryMock; - $jwsSerializerManagerFactory ??= $this->jwsSerializerManagerFactoryMock; - $jwsParserFactory ??= $this->jwsParserFactoryMock; - $jwsVerifierFactory ??= $this->jwsVerifierFactoryMock; - $timestampValidationLeeway ??= $this->timestampValidationLeewayMock; - $dateIntervalDecoratorFactory ??= $this->dateIntervalDecoratorFactoryMock; - $cacheDecoratorFactory ??= $this->cacheDecoratorFactoryMock; - $httpClientDecoratorFactory ??= $this->httpClientDecoratorFactoryMock; + $httpClient ??= $this->httpClientMock; return new Jwks( $supportedAlgorithms, $supportedSerializers, $maxCacheDuration, + $timestampValidationLeeway, $cache, - $httpClient, $logger, - $helpers, - $algorithmManagerFactory, - $jwsSerializerManagerFactory, - $jwsParserFactory, - $jwsVerifierFactory, - $timestampValidationLeeway, - $dateIntervalDecoratorFactory, - $cacheDecoratorFactory, - $httpClientDecoratorFactory, + $httpClient, ); } diff --git a/tests/src/Jws/JwsDecoratorTest.php b/tests/src/Jws/JwsDecoratorTest.php index a8dca10..297fc29 100644 --- a/tests/src/Jws/JwsDecoratorTest.php +++ b/tests/src/Jws/JwsDecoratorTest.php @@ -6,8 +6,8 @@ use Jose\Component\Signature\JWS; use PHPUnit\Framework\Attributes\CoversClass; -use SimpleSAML\OpenID\Jws\JwsDecorator; use PHPUnit\Framework\TestCase; +use SimpleSAML\OpenID\Jws\JwsDecorator; #[CoversClass(JwsDecorator::class)] class JwsDecoratorTest extends TestCase diff --git a/tests/src/Jws/JwsFetcherTest.php b/tests/src/Jws/JwsFetcherTest.php new file mode 100644 index 0000000..1061840 --- /dev/null +++ b/tests/src/Jws/JwsFetcherTest.php @@ -0,0 +1,168 @@ +parsedJwsFactoryMock = $this->createMock(ParsedJwsFactory::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->responseBodyMock = $this->createMock(StreamInterface::class); + $this->responseMock->method('getBody')->willReturn($this->responseBodyMock); + $this->artifactFetcherMock->method('fromNetwork')->willReturn($this->responseMock); + + $this->parsedJwsMock = $this->createMock(ParsedJws::class); + } + + protected function sut( + ?ParsedJwsFactory $parsedJwsFactory = null, + ?ArtifactFetcher $artifactFetcher = null, + ?DateIntervalDecorator $maxCacheDuration = null, + ?Helpers $helpers = null, + ?LoggerInterface $logger = null, + ): JwsFetcher { + $parsedJwsFactory ??= $this->parsedJwsFactoryMock; + $artifactFetcher ??= $this->artifactFetcherMock; + $maxCacheDuration ??= $this->maxCacheDurationMock; + $helpers ??= $this->helpersMock; + $logger ??= $this->loggerMock; + + return new JwsFetcher( + $parsedJwsFactory, + $artifactFetcher, + $maxCacheDuration, + $helpers, + $logger, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(JwsFetcher::class, $this->sut()); + } + + public function testCanFetchFromCache(): void + { + $this->artifactFetcherMock->expects($this->once())->method('fromCacheAsString') + ->with('uri') + ->willReturn('token'); + + $this->parsedJwsFactoryMock->expects($this->once())->method('fromToken') + ->with('token'); + + $this->assertInstanceOf(ParsedJws::class, $this->sut()->fromCache('uri')); + } + + public function testCanFetchFromCacheOrNetwork(): void + { + $this->artifactFetcherMock->expects($this->once())->method('fromCacheAsString') + ->with('uri') + ->willReturn(null); + + $this->responseMock->method('getStatusCode')->willReturn(200); + + $this->artifactFetcherMock->expects($this->once())->method('fromNetwork') + ->with('uri') + ->willReturn($this->responseMock); + + $this->assertInstanceOf( + ParsedJws::class, + $this->sut()->fromCacheOrNetwork('uri'), + ); + } + + public function testFetchFromNetworkThrowsForInvalidResponseStatusCode(): void + { + $this->artifactFetcherMock->expects($this->once())->method('fromNetwork') + ->with('uri'); + + $this->responseMock->method('getStatusCode')->willReturn(500); + + $this->expectException(FetchException::class); + $this->expectExceptionMessage('500'); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('500')); + + $this->sut()->fromNetwork('uri'); + } + + public function testChecksForExpectedContentTypeHttpHeader(): void + { + $sut = new class ( + $this->parsedJwsFactoryMock, + $this->artifactFetcherMock, + $this->maxCacheDurationMock, + $this->helpersMock, + $this->loggerMock, + ) extends JwsFetcher { + public function getExpectedContentTypeHttpHeader(): ?string + { + return 'application/jwt'; + } + }; + + $this->responseMock->method('getStatusCode')->willReturn(200); + + $this->expectException(FetchException::class); + $this->expectExceptionMessage('application/jwt'); + + $sut->fromNetwork('uri'); + } + + public function testWillUseJwsExpirationTimeWhenConsideringTtlForCaching(): void + { + $expirationTime = time() + 60; + $this->responseMock->method('getStatusCode')->willReturn(200); + + $this->parsedJwsMock->expects($this->once())->method('getExpirationTime') + ->willReturn($expirationTime); + + $this->parsedJwsFactoryMock->expects($this->once())->method('fromToken') + ->willReturn($this->parsedJwsMock); + + $this->maxCacheDurationMock->expects($this->once())->method('lowestInSecondsComparedToExpirationTime') + ->with($expirationTime) + ->willReturn(60); + + $this->artifactFetcherMock->expects($this->once())->method('cacheIt') + ->with($this->isType('string'), 60, 'uri'); + + + $this->sut()->fromNetwork('uri'); + } +} diff --git a/tests/src/Jws/JwsParserTest.php b/tests/src/Jws/JwsParserTest.php new file mode 100644 index 0000000..6e9869d --- /dev/null +++ b/tests/src/Jws/JwsParserTest.php @@ -0,0 +1,61 @@ +jwsSerializerManagerMock = $this->createMock(JwsSerializerManager::class); + $this->jwsMock = $this->createMock(JWS::class); + } + + protected function sut( + ?JwsSerializerManager $jwsSerializerManager = null, + ): JwsParser { + $jwsSerializerManager ??= $this->jwsSerializerManagerMock; + + return new JwsParser($jwsSerializerManager); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(JwsParser::class, $this->sut()); + } + + public function testCanParseToken(): void + { + $this->jwsSerializerManagerMock->expects($this->once())->method('unserialize') + ->willReturn($this->jwsMock); + + $this->assertInstanceOf(JwsDecorator::class, $this->sut()->parse('token')); + } + + public function testThrowsOnTokenParseError(): void + { + $this->jwsSerializerManagerMock->expects($this->once())->method('unserialize') + ->willThrowException(new \Exception('Error')); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('parse'); + + $this->sut()->parse('token'); + } +} diff --git a/tests/src/Jws/ParsedJwsTest.php b/tests/src/Jws/ParsedJwsTest.php new file mode 100644 index 0000000..ea0fea6 --- /dev/null +++ b/tests/src/Jws/ParsedJwsTest.php @@ -0,0 +1,400 @@ + 'RS256', + 'typ' => 'entity-statement+jwt', + 'kid' => 'LfgZECDYkSTHmbllBD5_Tkwvy3CtOpNYQ7-DfQawTww', + ]; + + protected array $expiredPayload = [ + 'iat' => 1731175727, + 'nbf' => 1731175727, + 'exp' => 1731175727, + 'iss' => 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/', + 'sub' => 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/', + 'jwks' => [ + 'keys' => [ + [ + 'alg' => 'RS256', + 'use' => 'sig', + 'kty' => 'RSA', + // phpcs:ignore + 'n' => 'pJgG9F_lwc2cFEC1l6q0fjJYxKPbtVGqJpDggDpDR8MgfbH0jUZP_RvhJGpl_09Bp-PfibLiwxchHZlrCx-fHQyGMaBRivUfq_p12ECEXMaFUcasCP6cyNrDfa5Uchumau4WeC21nYI1NMawiMiWFcHpLCQ7Ul8NMaCM_dkeruhm_xG0ZCqfwu30jOyCsnZdE0izJwPTfBRLpLyivu8eHpwjoIzmwqo8H-ZsbqR0vdRu20-MNS78ppTxwK3QmJhU6VO2r730F6WH9xJd_XUDuVeM4_6Z6WVDXw3kQF-jlpfcssPP303nbqVmfFZSUgS8buToErpMqevMIKREShsjMQ', + 'e' => 'AQAB', + 'kid' => 'F4VFObNusj3PHmrHxpqh4GNiuFHlfh-2s6xMJ95fLYA', + ], + ], + ], + 'metadata' => [ + 'federation_entity' => [ + 'organization_name' => 'Org ALeaf', + ], + 'openid_relying_party' => [ + 'redirect_uris' => [ + 'https://74-dap.localhost.markoivancic.from.hr/oidc/oidc-php-app-demo/callback.php', + ], + 'response_types' => [ + 'code', + ], + 'scope' => 'openid profile', + 'token_endpoint_auth_method' => 'self_signed_tls_client_auth', + 'contacts' => [ + 'rp_admins@rp.example.org', + ], + 'jwks_uri' => 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/jwks', + 'signed_jwks_uri' => 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ALeaf/signed-jwks', + ], + ], + 'authority_hints' => [ + 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/AIntermediate/', + ], + 'trust_marks' => [ + [ + 'id' => 'https://08-dap.localhost.markoivancic.from.hr/openid/entities/ABTrustAnchor/trust-mark/member', + // phpcs:ignore + 'trust_mark' => 'eyJhbGciOiJSUzI1NiIsInR5cCI6InRydXN0LW1hcmsrand0Iiwia2lkIjoiZnNRNDVGMEQ5MTZSZEtFZVRqdGE4RFlXaW9kanRob3VIclZXZ09YQnJrayJ9.eyJpYXQiOjE3MzQwMTcyMTcsIm5iZiI6MTczNDAxNzIxNywiZXhwIjoxNzM0MDIwODE3LCJpZCI6Imh0dHBzOlwvXC8wOC1kYXAubG9jYWxob3N0Lm1hcmtvaXZhbmNpYy5mcm9tLmhyXC9vcGVuaWRcL2VudGl0aWVzXC9BQlRydXN0QW5jaG9yXC90cnVzdC1tYXJrXC9tZW1iZXIiLCJpc3MiOiJodHRwczpcL1wvMDgtZGFwLmxvY2FsaG9zdC5tYXJrb2l2YW5jaWMuZnJvbS5oclwvb3BlbmlkXC9lbnRpdGllc1wvQUJUcnVzdEFuY2hvclwvIiwic3ViIjoiaHR0cHM6XC9cLzA4LWRhcC5sb2NhbGhvc3QubWFya29pdmFuY2ljLmZyb20uaHJcL29wZW5pZFwvZW50aXRpZXNcL0FMZWFmXC8ifQ.hbpq2-oPbn56WwDGLLcYaM7t8wZbipa_0FMlFT7nmRi6OZRibid5TGIBYs3Zk9nmNVZhzOCYO3inOIws6yJhpg6ogD32KpXet4oz8xeYftyw-xddb_sMf3gBPK5GChnqNsj71QJHZDYIUL3nILTySpnR2u7UK6gtmoosjxNINawM-teg0tIsOGaHuqDlAu9wSBI3PFxvXJJvi4mmMF3TosudexrpIHIBnNY_bvaSKJdzlmuSssWVAmIKp7O1IZLhn6eOzrhuktlGH5iltd77CnFxhdMyFjZrUOcT2MXIhZqWqpy-Uj-H2Bia63CwvmZ5DQa-WVYUSbxCEqJeeRqI0Q', + ], + ], + ]; + + protected array $validPayload; + + protected function setUp(): void + { + $this->jwsDecoratorMock = $this->createMock(JwsDecorator::class); + $this->jwsVerifierMock = $this->createMock(JwsVerifier::class); + $this->jwksFactoryMock = $this->createMock(JwksFactory::class); + $this->jwsSerializerManagerMock = $this->createMock(JwsSerializerManager::class); + $this->timestampValidationLeewayMock = $this->createMock(DateIntervalDecorator::class); + $this->helpersMock = $this->createMock(Helpers::class); + + $this->jwsMock = $this->createMock(JWS::class); + $this->jwsDecoratorMock->method('jws')->willReturn($this->jwsMock); + + $this->signatureMock = $this->createMock(Signature::class); + $this->jwsMock->method('getSignature')->willReturn($this->signatureMock); + + $this->jsonHelperMock = $this->createMock(Helpers\Json::class); + $this->helpersMock->method('json')->willReturn($this->jsonHelperMock); + + $this->validPayload = $this->expiredPayload; + $this->validPayload['exp'] = time() + 3600; + } + + protected function sut( + ?JwsDecorator $jwsDecorator = null, + ?JwsVerifier $jwsVerifier = null, + ?JwksFactory $jwksFactory = null, + ?JwsSerializerManager $jwsSerializerManager = null, + ?DateIntervalDecorator $timestampValidationLeewayMock = null, + ?Helpers $helpers = null, + ): ParsedJws { + $jwsDecorator ??= $this->jwsDecoratorMock; + $jwsVerifier ??= $this->jwsVerifierMock; + $jwksFactory ??= $this->jwksFactoryMock; + $jwsSerializerManager ??= $this->jwsSerializerManagerMock; + $timestampValidationLeewayMock ??= $this->timestampValidationLeewayMock; + $helpers ??= $this->helpersMock; + + return new ParsedJws( + $jwsDecorator, + $jwsVerifier, + $jwksFactory, + $jwsSerializerManager, + $timestampValidationLeewayMock, + $helpers, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ParsedJws::class, $this->sut()); + } + + public function testCanValidateByCallbacks(): void + { + $sut = new class ( + $this->jwsDecoratorMock, + $this->jwsVerifierMock, + $this->jwksFactoryMock, + $this->jwsSerializerManagerMock, + $this->timestampValidationLeewayMock, + $this->helpersMock, + ) extends ParsedJws { + protected function validate(): void + { + $this->validateByCallbacks($this->simulateOk(...)); + } + + protected function simulateOk(): void + { + } + }; + + $this->assertInstanceOf(ParsedJws::class, $sut); + } + + public function testThrowsOnValidateByCallbacksError(): void + { + $this->expectException(JwsException::class); + $this->expectExceptionMessage('not valid'); + + new class ( + $this->jwsDecoratorMock, + $this->jwsVerifierMock, + $this->jwksFactoryMock, + $this->jwsSerializerManagerMock, + $this->timestampValidationLeewayMock, + $this->helpersMock, + ) extends ParsedJws { + protected function validate(): void + { + $this->validateByCallbacks($this->simulateError(...)); + } + + protected function simulateError(): never + { + throw new \Exception('Error'); + } + }; + } + + public function testCanGetHeader(): void + { + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + + $this->assertSame($this->sampleHeader, $this->sut()->getHeader()); + } + + public function testCanGetHeaderClaims(): void + { + $this->signatureMock->method('getProtectedHeader')->willReturn($this->sampleHeader); + + $this->assertSame($this->sampleHeader['kid'], $this->sut()->getHeaderClaim('kid')); + $this->assertSame($this->sampleHeader['kid'], $this->sut()->getKeyId()); + $this->assertSame($this->sampleHeader['typ'], $this->sut()->getType()); + } + + public function testThrowsOnGetHeaderError(): void + { + $this->jwsMock->method('getSignature')->willThrowException(new \Exception('Error')); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('header'); + + $this->sut()->getHeader(); + } + + public function testCanGetPayload(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn($this->validPayload); + + $sut = $this->sut(); + + $this->assertSame($this->validPayload, $sut->getPayload()); + // Second call so that we verify that decoding happens only once. + $this->assertSame($this->validPayload, $sut->getPayload()); + } + + public function testCanGetEmptyPayload(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn(''); + $this->jsonHelperMock->expects($this->never())->method('decode'); + + $this->sut()->getPayload(); + } + + public function testThrowsOnPayloadDecodingError(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode') + ->willThrowException(new \JsonException('Error')); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('decode'); + + $this->sut()->getPayload(); + } + + public function testCanGetPayloadClaims(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn($this->validPayload); + + $sut = $this->sut(); + + $this->assertSame($this->validPayload['iss'], $sut->getPayloadClaim('iss')); + $this->assertSame($this->validPayload['iss'], $sut->getIssuer()); + $this->assertSame($this->validPayload['sub'], $sut->getSubject()); + $this->assertSame($this->validPayload['exp'], $sut->getExpirationTime()); + $this->assertSame($this->validPayload['iat'], $sut->getIssuedAt()); + } + + public function testCanGetEmptyPayloadClaims(): void + { + + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn([]); + + $sut = $this->sut(); + $this->assertNull($sut->getAudience()); + $this->assertNull($sut->getJwtId()); + $this->assertNull($sut->getExpirationTime()); + $this->assertNull($sut->getIssuedAt()); + $this->assertNull($sut->getIdentifier()); + } + + public function testCanGetAudienceArrayFromString(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode') + ->willReturn(['aud' => 'sample']); + + $this->assertSame(['sample'], $this->sut()->getAudience()); + } + + public function testCanGetAudienceArrayFromArray(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode') + ->willReturn(['aud' => ['sample']]); + + $this->assertSame(['sample'], $this->sut()->getAudience()); + } + + public function testThrowsOnInvalidAudienceValue(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode') + ->willReturn(['aud' => 123]); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('audience'); + + $this->sut()->getAudience(); + } + + public function testThrowsOnEmptyString(): void + { + $payload = ['iss' => '']; + + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Empty'); + + $this->sut()->getIssuer(); + } + + public function testThrowsOnEmptyStrings(): void + { + $sut = new class ( + $this->jwsDecoratorMock, + $this->jwsVerifierMock, + $this->jwksFactoryMock, + $this->jwsSerializerManagerMock, + $this->timestampValidationLeewayMock, + $this->helpersMock, + ) extends ParsedJws { + public function simulateEmptyStringsCase(): array + { + return $this->ensureNonEmptyStrings([''], 'test'); + } + }; + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Empty'); + + $sut->simulateEmptyStringsCase(); + } + + public function testCanSerializeToToken(): void + { + $this->jwsSerializerManagerMock->expects($this->once())->method('serialize') + ->willReturn('token'); + + $sut = $this->sut(); + + $this->assertSame('token', $sut->getToken()); + // Ensure that serialization happens only once. + $this->assertSame('token', $sut->getToken()); + } + + public function testCanVerifyWithKeySet(): void + { + $this->jwsVerifierMock->expects($this->once())->method('verifyWithKeySet') + ->willReturn(true); + + $this->sut()->verifyWithKeySet(['jwks']); + } + + public function testThrowsOnVerifyWithKeySetError(): void + { + $this->jwsVerifierMock->expects($this->once())->method('verifyWithKeySet') + ->willReturn(false); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('signature'); + + $this->sut()->verifyWithKeySet(['jwks']); + } + + public function testThrowsIfExpired(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn($this->expiredPayload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Expiration'); + + $this->sut()->getExpirationTime(); + } + + public function testThrowsIfIssuedAtInTheFuture(): void + { + $this->jwsMock->expects($this->once())->method('getPayload')->willReturn('payload-json'); + $payload = $this->validPayload; + $payload['iat'] = time() + 60; + $this->jsonHelperMock->expects($this->once())->method('decode')->willReturn($payload); + + $this->expectException(JwsException::class); + $this->expectExceptionMessage('Issued At'); + + $this->sut()->getIssuedAt(); + } +} diff --git a/tests/src/Utils/ArtifactFetcherTest.php b/tests/src/Utils/ArtifactFetcherTest.php new file mode 100644 index 0000000..5073e48 --- /dev/null +++ b/tests/src/Utils/ArtifactFetcherTest.php @@ -0,0 +1,174 @@ +httpClientDecoratorMock = $this->createMock(HttpClientDecorator::class); + $this->cacheDecoratorMock = $this->createMock(CacheDecorator::class); + $this->loggerMock = $this->createMock(LoggerInterface::class); + + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->responseBodyMock = $this->createMock(StreamInterface::class); + $this->responseMock->method('getBody')->willReturn($this->responseBodyMock); + } + + protected function sut( + ?HttpClientDecorator $httpClientDecorator = null, + ?CacheDecorator $cacheDecorator = null, + ?LoggerInterface $logger = null, + ): ArtifactFetcher { + $httpClientDecorator ??= $this->httpClientDecoratorMock; + $cacheDecorator ??= $this->cacheDecoratorMock; + $logger ??= $this->loggerMock; + + return new ArtifactFetcher( + $httpClientDecorator, + $cacheDecorator, + $logger, + ); + } + + public function testCanCreateInstance(): void + { + $this->assertInstanceOf(ArtifactFetcher::class, $this->sut()); + } + + public function testReturnsNullIfCacheNotAvailable(): void + { + $sut = new ArtifactFetcher($this->httpClientDecoratorMock, null, $this->loggerMock); + + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('skipping')); + + $this->assertNull($sut->fromCacheAsString('key')); + } + + public function testReturnsNullIfNotInCache(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'key') + ->willReturn(null); + + $this->assertNull($this->sut()->fromCacheAsString('key')); + } + + public function testReturnsArtifactIfString(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'key') + ->willReturn('artifact'); + + $this->assertSame('artifact', $this->sut()->fromCacheAsString('key')); + } + + public function testReturnsNullIfNotString(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'key') + ->willReturn(['artifact-in-array']); + + $this->loggerMock->expects($this->once())->method('warning') + ->with($this->stringContains('unexpected')); + + $this->assertNull($this->sut()->fromCacheAsString('key')); + } + + public function testReturnsNullOnCacheFailure(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('get') + ->with(null, 'key') + ->willThrowException(new \Exception('Error')); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('error')); + + $this->assertNull($this->sut()->fromCacheAsString('key')); + } + + public function testCanFetchFromNetwork(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->with(HttpMethodsEnum::GET, 'uri'); + + $this->assertInstanceOf(ResponseInterface::class, $this->sut()->fromNetwork('uri')); + } + + public function testFromNetworkThrowsOnNetworkError(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->willThrowException(new \Exception('Error')); + + $this->expectException(FetchException::class); + $this->expectExceptionMessage('HTTP'); + + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('error')); + + $this->sut()->fromNetwork('uri'); + } + + public function testCanFetchFromNetworkAsString(): void + { + $this->httpClientDecoratorMock->expects($this->once())->method('request') + ->willReturn($this->responseMock); + $this->responseBodyMock->method('getContents')->willReturn('artifact'); + + $this->assertSame('artifact', $this->sut()->fromNetworkAsString('uri')); + } + + public function testCanCacheArtifact(): void + { + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->with('artifact', 60, 'key'); + + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('saved')); + + $this->sut()->cacheIt('artifact', 60, 'key'); + } + + public function testSkipsCachingIfCacheNotAvailable(): void + { + $this->loggerMock->expects($this->once())->method('debug') + ->with($this->stringContains('skipping')); + + $sut = new ArtifactFetcher($this->httpClientDecoratorMock, null, $this->loggerMock); + + $sut->cacheIt('artifact', 60, 'key'); + } + + public function testCanLogCacheError(): void + { + $this->loggerMock->expects($this->once())->method('error') + ->with($this->stringContains('error')); + + $this->cacheDecoratorMock->expects($this->once())->method('set') + ->willThrowException(new \Exception('Error')); + + $this->sut()->cacheIt('artifact', 60, 'key'); + } +}