From 6c08edc28c8ef59c286319b156ccfd8628882683 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Thu, 5 Dec 2024 17:28:06 +0100 Subject: [PATCH 01/14] feat: token validator trait --- .gitignore | 3 +- composer.json | 3 +- src/Provider/CachedFile.php | 125 ++++++ .../Exception/AudienceInvalidException.php | 7 + .../Exception/KidInvalidException.php | 7 + .../Exception/ScopeInvalidException.php | 7 + .../Exception/SignatureInvalidException.php | 7 + .../Exception/TokenExpiredException.php | 7 + .../Exception/TokenInvalidException.php | 7 + src/Provider/PrestaShop.php | 53 +-- src/Provider/{ => Traits}/LogoutTrait.php | 2 +- src/Provider/Traits/TokenValidatorTrait.php | 111 +++++ tests/src/Provider/PrestaShopTest.php | 27 +- .../Provider/{ => Traits}/LogoutTraitTest.php | 2 +- .../Traits/TokenValidatorTraitTest.php | 391 ++++++++++++++++++ tests/src/TestCase.php | 32 ++ 16 files changed, 736 insertions(+), 55 deletions(-) create mode 100644 src/Provider/CachedFile.php create mode 100644 src/Provider/Exception/AudienceInvalidException.php create mode 100644 src/Provider/Exception/KidInvalidException.php create mode 100644 src/Provider/Exception/ScopeInvalidException.php create mode 100644 src/Provider/Exception/SignatureInvalidException.php create mode 100644 src/Provider/Exception/TokenExpiredException.php create mode 100644 src/Provider/Exception/TokenInvalidException.php rename src/Provider/{ => Traits}/LogoutTrait.php (97%) create mode 100644 src/Provider/Traits/TokenValidatorTrait.php rename tests/src/Provider/{ => Traits}/LogoutTraitTest.php (98%) create mode 100644 tests/src/Provider/Traits/TokenValidatorTraitTest.php create mode 100644 tests/src/TestCase.php diff --git a/.gitignore b/.gitignore index 0e440b8..627b7ae 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ composer.lock .DS_Store /.idea .phpunit* -clover.xml \ No newline at end of file +clover.xml +/tests/var \ No newline at end of file diff --git a/composer.json b/composer.json index 88250ad..b06a882 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ ], "require": { "php": ">=5.6", - "league/oauth2-client": "^2.0" + "league/oauth2-client": "^2.0", + "firebase/php-jwt": "6.00" }, "require-dev": { "phpunit/phpunit": "^8.0 || ^9.0", diff --git a/src/Provider/CachedFile.php b/src/Provider/CachedFile.php new file mode 100644 index 0000000..d006227 --- /dev/null +++ b/src/Provider/CachedFile.php @@ -0,0 +1,125 @@ +filename = $filename; + $this->ttl = $ttl; + + $this->initDirectory(); + $this->assertReadable(); + $this->assertWritable(); + } + + /** + * @return bool + */ + public function isExpired() + { + if (file_exists($this->filename)) { + if ($this->ttl === null) { + return false; + } + return time() - filemtime($this->filename) > $this->ttl; + } + return true; + } + + /** + * @return false|string + */ + public function read() + { + return file_get_contents($this->filename); + } + + /** + * @param mixed $content + * + * @return void + */ + public function write($content) + { + file_put_contents($this->filename, $content); + } + + /** + * @return void + */ + public function clear() + { + if (file_exists($this->filename)) { + unlink($this->filename); + } + } + + /** + * @return string + */ + public function getFilename() + { + return $this->filename; + } + + /** + * @return int|null + */ + public function getTtl() + { + return $this->ttl; + } + + /** + * @return bool + */ + protected function initDirectory() + { + if (!file_exists(dirname($this->filename))) { + return mkdir(dirname($this->filename), 0755, true); + } + return true; + } + + /** + * @return void + * + * @throws \Exception + */ + protected function assertReadable() + { + if (!is_readable($this->filename) && !is_readable(dirname($this->filename))) { + throw new \Exception('File "' . $this->filename . '" is not readable.'); + } + } + + /** + * @return void + * + * @throws \Exception + */ + protected function assertWritable() + { + if (!is_writable($this->filename) && !is_writeable(dirname($this->filename))) { + throw new \Exception('File "' . $this->filename . '" is not writable.'); + } + } +} \ No newline at end of file diff --git a/src/Provider/Exception/AudienceInvalidException.php b/src/Provider/Exception/AudienceInvalidException.php new file mode 100644 index 0000000..2bba5be --- /dev/null +++ b/src/Provider/Exception/AudienceInvalidException.php @@ -0,0 +1,7 @@ +wellKnown)) { try { $this->wellKnown = new WellKnown( $this->fetchWellKnown($this->getOauth2Url(), $this->verify) ); - } catch (\Error $e) { + /* @phpstan-ignore-next-line */ + } catch (\Throwable $e) { } catch (\Exception $e) { } if (isset($e)) { @@ -151,6 +154,29 @@ public function getResourceOwnerDetailsUrl(AccessToken $token) return $this->getWellKnown()->userinfo_endpoint; } + /** + * @return string[] + */ + public function getDefaultScopes() + { + return ['openid', 'offline_access']; + } + + /** + * Requests and returns the resource owner of given access token. + * + * @param AccessToken $token + * + * @return PrestaShopUser + */ + public function getResourceOwner(AccessToken $token) + { + /** @var PrestaShopUser $resourceOwner */ + $resourceOwner = parent::getResourceOwner($token); + + return $resourceOwner; + } + /** * @param array $options * @@ -175,14 +201,6 @@ protected function getAuthorizationParameters(array $options) return $options; } - /** - * @return string[] - */ - public function getDefaultScopes() - { - return ['openid', 'offline_access']; - } - /** * @return string */ @@ -222,19 +240,4 @@ protected function createResourceOwner(array $response, AccessToken $token) { return new PrestaShopUser($response); } - - /** - * Requests and returns the resource owner of given access token. - * - * @param AccessToken $token - * - * @return PrestaShopUser - */ - public function getResourceOwner(AccessToken $token) - { - /** @var PrestaShopUser $resourceOwner */ - $resourceOwner = parent::getResourceOwner($token); - - return $resourceOwner; - } } diff --git a/src/Provider/LogoutTrait.php b/src/Provider/Traits/LogoutTrait.php similarity index 97% rename from src/Provider/LogoutTrait.php rename to src/Provider/Traits/LogoutTrait.php index 0622716..51a2252 100644 --- a/src/Provider/LogoutTrait.php +++ b/src/Provider/Traits/LogoutTrait.php @@ -1,6 +1,6 @@ cachedJwks) { + throw new \Exception('Cache file not configured'); + } + + if ($this->cachedJwks->isExpired() || $forceRefresh) { + $this->cachedJwks->write( + $this->getResponse( + $this->getRequest('GET', $this->getWellKnown()->jwks_uri) + )->getBody() + ); + } + return json_decode($this->cachedJwks->read(), true); + } + + /** + * @param string $token + * + * @return object decoded token + * + * @throws Exception\SignatureInvalidException + * @throws Exception\TokenExpiredException + * @throws Exception\TokenInvalidException + */ + public function verifyToken($token, $refreshJwks = false) + { + // verify token signature & expiration (among others) + try { + $token = JWT::decode($token, JWK::parseKeySet($this->getJwks($refreshJwks))); + } catch (ExpiredException $e) { + throw new Exception\TokenExpiredException($e->getMessage()); + } catch (SignatureInvalidException $e) { + throw new Exception\SignatureInvalidException($e->getMessage()); + } catch (\UnexpectedValueException $e) { + // FIXME: check kid header by ourselves + if (!$refreshJwks && $e->getMessage() == '"kid" invalid, unable to lookup correct key') { + return $this->verifyToken($token, true); + } + throw new KidInvalidException($e->getMessage()); + } catch (\Throwable $e) { + throw new Exception\TokenInvalidException($e->getMessage()); + } catch (\Exception $e) { + throw new Exception\TokenInvalidException($e->getMessage()); + } + return $token; + } + + + /** + * @param string $token string token to be validated + * @param array $scope expected scope(s)) + * @param array $audience expected audience(s) + * + * @return object decoded token + * + * @throws Exception\AudienceInvalidException + * @throws Exception\ScopeInvalidException + * @throws Exception\SignatureInvalidException + * @throws Exception\TokenExpiredException + * @throws Exception\TokenInvalidException + */ + public function validateToken($token, array $scope = [], array $audience = []) + { + $token = $this->verifyToken($token); + + // check expected scopes are included + $scp = is_array($token->scp) ? array_unique($token->scp) : []; + if (count(array_intersect($scope, $scp)) < count($scope)) { + throw new Exception\ScopeInvalidException( + 'Expected scopes not matched: ' . implode(', ', $scope) + ); + } + + // check expected audiences are included + $aud = is_array($token->aud) ? array_unique($token->aud) : []; + if (count(array_intersect($audience, $aud)) < count($audience)) { + throw new Exception\AudienceInvalidException( + 'Expected audiences not matched: ' . implode(', ', $audience) + ); + } + + return $token; + } +} \ No newline at end of file diff --git a/tests/src/Provider/PrestaShopTest.php b/tests/src/Provider/PrestaShopTest.php index 7aa6d44..3344db2 100644 --- a/tests/src/Provider/PrestaShopTest.php +++ b/tests/src/Provider/PrestaShopTest.php @@ -3,14 +3,12 @@ namespace PrestaShop\OAuth2\Client\Test\Provider; use GuzzleHttp\ClientInterface; -use GuzzleHttp\Psr7\Utils; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Token\AccessToken; -use PHPUnit\Framework\TestCase; use PrestaShop\OAuth2\Client\Provider\PrestaShop; use PrestaShop\OAuth2\Client\Provider\PrestaShopUser; use PrestaShop\OAuth2\Client\Provider\WellKnown; -use Psr\Http\Message\ResponseInterface; +use PrestaShop\OAuth2\Client\Test\TestCase; class PrestaShopTest extends TestCase { @@ -45,29 +43,6 @@ protected function setUp(): void ])); } - /** - * @param $responseBody - * @param $statusCode - * - * @return \PHPUnit_Framework_MockObject_MockObject|ResponseInterface|(ResponseInterface&\PHPUnit_Framework_MockObject_MockObject) - */ - private function createMockResponse($responseBody, $statusCode = 200) - { - $response = $this->createMock(ResponseInterface::class); - - $response->method('getStatusCode') - ->willReturn($statusCode); - - $response->method('getBody') - ->willReturn(Utils::streamFor($responseBody)); - - $response->method('getHeader') - ->with('content-type') - ->willReturn(['application/json']); - - return $response; - } - /** * @test */ diff --git a/tests/src/Provider/LogoutTraitTest.php b/tests/src/Provider/Traits/LogoutTraitTest.php similarity index 98% rename from tests/src/Provider/LogoutTraitTest.php rename to tests/src/Provider/Traits/LogoutTraitTest.php index 5d5d5fa..d6884cc 100644 --- a/tests/src/Provider/LogoutTraitTest.php +++ b/tests/src/Provider/Traits/LogoutTraitTest.php @@ -1,6 +1,6 @@ cachedFile = new CachedFile(__DIR__ . '/../../../var/cache/jwks.json'); + $this->provider = new PrestaShop([ + 'clientId' => 'test-client', + 'clientSecret' => 'secret', + 'redirectUri' => 'https://test-client-redirect.net', + 'cachedJwks' => $this->cachedFile, + 'uiLocales' => ['fr-CA', 'en'], + 'acrValues' => ['prompt:login'], + ]); + + $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); + $this->jwksResponse = $this->createMockResponse($this->jwks); + + $this->cachedFile->clear(); + $this->initHttpClient(); + } + + /** + * @test + */ + public function itShouldFailIfCachedFileNotConfigured() + { + $this->expectException(\Exception::class); + + $this->provider = new PrestaShop([ + 'clientId' => 'test-client', + 'clientSecret' => 'secret', + 'redirectUri' => 'https://test-client-redirect.net', + // 'cachedJwks' => $this->cachedFile, + 'uiLocales' => ['fr-CA', 'en'], + 'acrValues' => ['prompt:login'], + ]); + + $this->provider->getJwks(); + } + + /** + * @test + */ + public function itShouldStoreCachedJwks() + { + $this->assertFileDoesNotExist($this->cachedFile->getFilename()); + + $this->provider->getJwks(); + + $this->assertFileExists($this->cachedFile->getFilename()); + } + + /** + * @test + */ + public function itShouldVerifyValidSignature() + { + $token = $this->provider->verifyToken(JWT::encode([ + 'aud' => [ + 'https://mashop.net' + ], + ], $this->privateKey, 'RS256', 'public:hydra.jwt.access-token')); + + $this->assertEquals('https://mashop.net', $token->aud[0]); + } + + /** + * @test + */ + public function itShouldNotVerifyInvalidSignature() + { + $this->expectException(SignatureInvalidException::class); + + $this->provider->verifyToken(JWT::encode([ + 'aud' => [ + 'https://mashop.net' + ], + ], $this->privateKey2, 'RS256', 'public:hydra.jwt.access-token')); + } + + /** + * @test + */ + public function itShouldRefreshJwksOnInvalidKid() + { + // cache jwks version 1 + $this->jwksResponse = $this->createMockResponse($this->jwks); + $this->provider->getJwks(); + + // rotate to jwks version 2 + $this->jwksResponse = $this->createMockResponse($this->jwks2); + + $token = $this->provider->verifyToken(JWT::encode([ + 'aud' => [ + 'https://mashop.net' + ], + ], $this->privateKey2, 'RS256', 'public:hydra.jwt.access-token2')); + + $this->assertEquals('https://mashop.net', $token->aud[0]); + } + + /** + * @test + */ + public function itShouldFailOnInvalidKid() + { + $this->expectException(KidInvalidException::class); + + $this->provider->verifyToken(JWT::encode([ + 'aud' => [ + 'https://mashop.net' + ], + ], $this->privateKey2, 'RS256', 'naughty-kid')); + } + + /** + * @test + */ + public function itShouldValidateToken() + { + $jwtString = $this->encodeToken([ + 'aud' => [ + 'https://mashop.net' + ], + 'scp' => [ + 'entity.read', + 'entity.write', + 'entity.delete', + ] + ]); + + $token = $this->provider->validateToken($jwtString, [ + 'entity.read', + 'entity.write', + ], [ + 'https://mashop.net' + ]); + + $this->assertEquals('https://mashop.net', $token->aud[0]); + + $token = $this->provider->validateToken($jwtString, [ + ], [ + 'https://mashop.net' + ]); + + $this->assertEquals('https://mashop.net', $token->aud[0]); + + $token = $this->provider->validateToken($jwtString, [ + 'entity.read', + 'entity.write', + ]); + + $this->assertEquals('https://mashop.net', $token->aud[0]); + } + + /** + * @test + */ + public function itShouldNotValidateTokenWithInvalidAudience() + { + $this->expectException(AudienceInvalidException::class); + + $jwtString = $this->encodeToken([ + 'aud' => [ + 'https://mashop.net' + ], + 'scp' => [ + 'entity.read', + 'entity.write', + 'entity.delete', + ] + ]); + + $this->provider->validateToken($jwtString, [ + 'entity.read', + 'entity.write', + ], [ + 'https://shopifees.net' + ]); + } + + /** + * @test + */ + public function itShouldNotValidateTokenWithInvalidScopes() + { + $this->expectException(ScopeInvalidException::class); + + $jwtString = $this->encodeToken([ + 'aud' => [ + 'https://mashop.net' + ], + 'scp' => [ + 'entity.red', + 'entity.write', + 'entity.delete', + ] + ]); + + $this->provider->validateToken($jwtString, [ + 'entity.read', + 'entity.write', + ], [ + 'https://mashop.net' + ]); + } + + /** + * @param array $payload + * @param string $privateKey + * @param string $kid + * + * @return string + */ + private function encodeToken(array $payload, $privateKey = null, $kid = 'public:hydra.jwt.access-token') + { + if ($privateKey === null) { + $privateKey = $this->privateKey; + } + return JWT::encode($payload, $privateKey, 'RS256', $kid); + } + + /** + * @return void + */ + private function initHttpClient() + { + $client = $this->createMock(ClientInterface::class); + $client->method('send') + ->willReturnCallback(function ($request) use ($client) { + /** @var RequestInterface $request */ + if (preg_match('/jwks\.json$/', $request->getUri())) { + return $this->jwksResponse; + } + if (preg_match('/openid\-configuration/', $request->getUri())) { + return $this->wellKnownResponse; + } + }); + $this->provider->setHttpClient($client); + } +} diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php new file mode 100644 index 0000000..89f890e --- /dev/null +++ b/tests/src/TestCase.php @@ -0,0 +1,32 @@ +createMock(ResponseInterface::class); + + $response->method('getStatusCode') + ->willReturn($statusCode); + + $response->method('getBody') + ->willReturn(Utils::streamFor($responseBody)); + + $response->method('getHeader') + ->with('content-type') + ->willReturn(['application/json']); + + return $response; + } +} \ No newline at end of file From 75fb7cae7538e3ed6b19f68db479a17372be9bb4 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Thu, 5 Dec 2024 17:31:10 +0100 Subject: [PATCH 02/14] chore: php-cs-fixer --- src/Provider/CachedFile.php | 7 ++-- .../Exception/AudienceInvalidException.php | 2 +- .../Exception/KidInvalidException.php | 2 +- .../Exception/ScopeInvalidException.php | 2 +- .../Exception/SignatureInvalidException.php | 2 +- .../Exception/TokenExpiredException.php | 2 +- .../Exception/TokenInvalidException.php | 2 +- src/Provider/Traits/TokenValidatorTrait.php | 13 +++----- .../Traits/TokenValidatorTraitTest.php | 33 +++++++++---------- tests/src/TestCase.php | 2 +- 10 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/Provider/CachedFile.php b/src/Provider/CachedFile.php index d006227..52c59ff 100644 --- a/src/Provider/CachedFile.php +++ b/src/Provider/CachedFile.php @@ -20,7 +20,7 @@ class CachedFile * * @throws \Exception */ - public function __construct($filename, $ttl=null) + public function __construct($filename, $ttl = null) { $this->filename = $filename; $this->ttl = $ttl; @@ -39,8 +39,10 @@ public function isExpired() if ($this->ttl === null) { return false; } + return time() - filemtime($this->filename) > $this->ttl; } + return true; } @@ -96,6 +98,7 @@ protected function initDirectory() if (!file_exists(dirname($this->filename))) { return mkdir(dirname($this->filename), 0755, true); } + return true; } @@ -122,4 +125,4 @@ protected function assertWritable() throw new \Exception('File "' . $this->filename . '" is not writable.'); } } -} \ No newline at end of file +} diff --git a/src/Provider/Exception/AudienceInvalidException.php b/src/Provider/Exception/AudienceInvalidException.php index 2bba5be..46e1fed 100644 --- a/src/Provider/Exception/AudienceInvalidException.php +++ b/src/Provider/Exception/AudienceInvalidException.php @@ -4,4 +4,4 @@ class AudienceInvalidException extends TokenInvalidException { -} \ No newline at end of file +} diff --git a/src/Provider/Exception/KidInvalidException.php b/src/Provider/Exception/KidInvalidException.php index 775e48e..c648e1e 100644 --- a/src/Provider/Exception/KidInvalidException.php +++ b/src/Provider/Exception/KidInvalidException.php @@ -4,4 +4,4 @@ class KidInvalidException extends TokenInvalidException { -} \ No newline at end of file +} diff --git a/src/Provider/Exception/ScopeInvalidException.php b/src/Provider/Exception/ScopeInvalidException.php index c35e7f3..b63cd01 100644 --- a/src/Provider/Exception/ScopeInvalidException.php +++ b/src/Provider/Exception/ScopeInvalidException.php @@ -4,4 +4,4 @@ class ScopeInvalidException extends TokenInvalidException { -} \ No newline at end of file +} diff --git a/src/Provider/Exception/SignatureInvalidException.php b/src/Provider/Exception/SignatureInvalidException.php index 828339b..69e097c 100644 --- a/src/Provider/Exception/SignatureInvalidException.php +++ b/src/Provider/Exception/SignatureInvalidException.php @@ -4,4 +4,4 @@ class SignatureInvalidException extends TokenInvalidException { -} \ No newline at end of file +} diff --git a/src/Provider/Exception/TokenExpiredException.php b/src/Provider/Exception/TokenExpiredException.php index 2417395..30ccb6e 100644 --- a/src/Provider/Exception/TokenExpiredException.php +++ b/src/Provider/Exception/TokenExpiredException.php @@ -4,4 +4,4 @@ class TokenExpiredException extends TokenInvalidException { -} \ No newline at end of file +} diff --git a/src/Provider/Exception/TokenInvalidException.php b/src/Provider/Exception/TokenInvalidException.php index 9edab01..2b3c441 100644 --- a/src/Provider/Exception/TokenInvalidException.php +++ b/src/Provider/Exception/TokenInvalidException.php @@ -4,4 +4,4 @@ class TokenInvalidException extends \Exception { -} \ No newline at end of file +} diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index 1024ed6..ba39b4f 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -37,6 +37,7 @@ public function getJwks($forceRefresh = false) )->getBody() ); } + return json_decode($this->cachedJwks->read(), true); } @@ -69,10 +70,10 @@ public function verifyToken($token, $refreshJwks = false) } catch (\Exception $e) { throw new Exception\TokenInvalidException($e->getMessage()); } + return $token; } - /** * @param string $token string token to be validated * @param array $scope expected scope(s)) @@ -93,19 +94,15 @@ public function validateToken($token, array $scope = [], array $audience = []) // check expected scopes are included $scp = is_array($token->scp) ? array_unique($token->scp) : []; if (count(array_intersect($scope, $scp)) < count($scope)) { - throw new Exception\ScopeInvalidException( - 'Expected scopes not matched: ' . implode(', ', $scope) - ); + throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scope)); } // check expected audiences are included $aud = is_array($token->aud) ? array_unique($token->aud) : []; if (count(array_intersect($audience, $aud)) < count($audience)) { - throw new Exception\AudienceInvalidException( - 'Expected audiences not matched: ' . implode(', ', $audience) - ); + throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $audience)); } return $token; } -} \ No newline at end of file +} diff --git a/tests/src/Provider/Traits/TokenValidatorTraitTest.php b/tests/src/Provider/Traits/TokenValidatorTraitTest.php index 6b86e3a..f3cad87 100644 --- a/tests/src/Provider/Traits/TokenValidatorTraitTest.php +++ b/tests/src/Provider/Traits/TokenValidatorTraitTest.php @@ -102,7 +102,6 @@ class TokenValidatorTraitTest extends TestCase -----END PRIVATE KEY----- EOD; - // https://pem2jwk.vercel.app/ private $jwks = <<provider->verifyToken(JWT::encode([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], ], $this->privateKey, 'RS256', 'public:hydra.jwt.access-token')); @@ -223,7 +221,7 @@ public function itShouldNotVerifyInvalidSignature() $this->provider->verifyToken(JWT::encode([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], ], $this->privateKey2, 'RS256', 'public:hydra.jwt.access-token')); } @@ -242,7 +240,7 @@ public function itShouldRefreshJwksOnInvalidKid() $token = $this->provider->verifyToken(JWT::encode([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], ], $this->privateKey2, 'RS256', 'public:hydra.jwt.access-token2')); @@ -258,7 +256,7 @@ public function itShouldFailOnInvalidKid() $this->provider->verifyToken(JWT::encode([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], ], $this->privateKey2, 'RS256', 'naughty-kid')); } @@ -270,27 +268,27 @@ public function itShouldValidateToken() { $jwtString = $this->encodeToken([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], 'scp' => [ 'entity.read', 'entity.write', 'entity.delete', - ] + ], ]); $token = $this->provider->validateToken($jwtString, [ 'entity.read', 'entity.write', ], [ - 'https://mashop.net' + 'https://mashop.net', ]); $this->assertEquals('https://mashop.net', $token->aud[0]); $token = $this->provider->validateToken($jwtString, [ ], [ - 'https://mashop.net' + 'https://mashop.net', ]); $this->assertEquals('https://mashop.net', $token->aud[0]); @@ -312,20 +310,20 @@ public function itShouldNotValidateTokenWithInvalidAudience() $jwtString = $this->encodeToken([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], 'scp' => [ 'entity.read', 'entity.write', 'entity.delete', - ] + ], ]); $this->provider->validateToken($jwtString, [ 'entity.read', 'entity.write', ], [ - 'https://shopifees.net' + 'https://shopifees.net', ]); } @@ -338,20 +336,20 @@ public function itShouldNotValidateTokenWithInvalidScopes() $jwtString = $this->encodeToken([ 'aud' => [ - 'https://mashop.net' + 'https://mashop.net', ], 'scp' => [ 'entity.red', 'entity.write', 'entity.delete', - ] + ], ]); $this->provider->validateToken($jwtString, [ 'entity.read', 'entity.write', ], [ - 'https://mashop.net' + 'https://mashop.net', ]); } @@ -367,6 +365,7 @@ private function encodeToken(array $payload, $privateKey = null, $kid = 'public: if ($privateKey === null) { $privateKey = $this->privateKey; } + return JWT::encode($payload, $privateKey, 'RS256', $kid); } @@ -377,7 +376,7 @@ private function initHttpClient() { $client = $this->createMock(ClientInterface::class); $client->method('send') - ->willReturnCallback(function ($request) use ($client) { + ->willReturnCallback(function ($request) { /** @var RequestInterface $request */ if (preg_match('/jwks\.json$/', $request->getUri())) { return $this->jwksResponse; diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index 89f890e..b25d3ef 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -29,4 +29,4 @@ protected function createMockResponse($responseBody, $statusCode = 200) return $response; } -} \ No newline at end of file +} From 989dfd72a21769e2d02605549a3bd5bec2407a66 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Thu, 5 Dec 2024 17:35:33 +0100 Subject: [PATCH 03/14] chore: php-cs-fixer --- src/Provider/PrestaShop.php | 3 ++- src/Provider/Traits/TokenValidatorTrait.php | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Provider/PrestaShop.php b/src/Provider/PrestaShop.php index 91bf2cc..be230d3 100644 --- a/src/Provider/PrestaShop.php +++ b/src/Provider/PrestaShop.php @@ -1,4 +1,5 @@ wellKnown = new WellKnown( $this->fetchWellKnown($this->getOauth2Url(), $this->verify) ); - /* @phpstan-ignore-next-line */ } catch (\Throwable $e) { + /* @phpstan-ignore-next-line */ } catch (\Exception $e) { } if (isset($e)) { diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index ba39b4f..911926d 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -67,6 +67,7 @@ public function verifyToken($token, $refreshJwks = false) throw new KidInvalidException($e->getMessage()); } catch (\Throwable $e) { throw new Exception\TokenInvalidException($e->getMessage()); + /* @phpstan-ignore-next-line */ } catch (\Exception $e) { throw new Exception\TokenInvalidException($e->getMessage()); } From 08d1930d503eab0c9f1e85ee6c9ba6a52834f1a1 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Thu, 5 Dec 2024 17:42:05 +0100 Subject: [PATCH 04/14] chore: ci fixes --- src/Provider/PrestaShop.php | 1 + src/Provider/Traits/TokenValidatorTrait.php | 1 + tests/src/Provider/Traits/TokenValidatorTraitTest.php | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Provider/PrestaShop.php b/src/Provider/PrestaShop.php index be230d3..847a81c 100644 --- a/src/Provider/PrestaShop.php +++ b/src/Provider/PrestaShop.php @@ -90,6 +90,7 @@ public function getOauth2Url() */ public function getWellKnown() { + /* @phpstan-ignore-next-line */ if (!isset($this->wellKnown)) { try { $this->wellKnown = new WellKnown( diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index 911926d..bb505f4 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -43,6 +43,7 @@ public function getJwks($forceRefresh = false) /** * @param string $token + * @param bool $refreshJwks * * @return object decoded token * diff --git a/tests/src/Provider/Traits/TokenValidatorTraitTest.php b/tests/src/Provider/Traits/TokenValidatorTraitTest.php index f3cad87..3b1d8e5 100644 --- a/tests/src/Provider/Traits/TokenValidatorTraitTest.php +++ b/tests/src/Provider/Traits/TokenValidatorTraitTest.php @@ -191,11 +191,11 @@ public function itShouldFailIfCachedFileNotConfigured() */ public function itShouldStoreCachedJwks() { - $this->assertFileDoesNotExist($this->cachedFile->getFilename()); + $this->assertFalse(file_exists($this->cachedFile->getFilename())); $this->provider->getJwks(); - $this->assertFileExists($this->cachedFile->getFilename()); + $this->assertTrue(file_exists($this->cachedFile->getFilename())); } /** From bc2866cb33f9b4450d6928555d243a7cc2a8a32e Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Thu, 5 Dec 2024 17:52:33 +0100 Subject: [PATCH 05/14] chore: ci fixes --- tests/script/genkeys | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 tests/script/genkeys diff --git a/tests/script/genkeys b/tests/script/genkeys new file mode 100644 index 0000000..5a4528d --- /dev/null +++ b/tests/script/genkeys @@ -0,0 +1,6 @@ +#!/bin/bash +openssl genrsa -out private_key.pem 2048 +openssl rsa -pubout -in private_key.pem -out public_key.pem + +openssl genrsa -out private_key2.pem 2048 +openssl rsa -pubout -in private_key2.pem -out public_key2.pem From ce2668860953c779c4d66902872c15212b563755 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Fri, 6 Dec 2024 12:05:51 +0100 Subject: [PATCH 06/14] feat: cached openid-configuration unit tests enhancements & fully local --- src/Provider/CachedFile.php | 2 +- src/Provider/PrestaShop.php | 43 ++++- tests/src/Provider/PrestaShopTest.php | 174 ++++++++++++------ tests/src/Provider/Traits/LogoutTraitTest.php | 31 +++- .../Traits/TokenValidatorTraitTest.php | 59 ++---- tests/src/TestCase.php | 61 ++++++ 6 files changed, 261 insertions(+), 109 deletions(-) diff --git a/src/Provider/CachedFile.php b/src/Provider/CachedFile.php index 52c59ff..6ed4aa9 100644 --- a/src/Provider/CachedFile.php +++ b/src/Provider/CachedFile.php @@ -23,7 +23,7 @@ class CachedFile public function __construct($filename, $ttl = null) { $this->filename = $filename; - $this->ttl = $ttl; + $this->ttl = (int) $ttl; $this->initDirectory(); $this->assertReadable(); diff --git a/src/Provider/PrestaShop.php b/src/Provider/PrestaShop.php index 847a81c..2ca5cad 100644 --- a/src/Provider/PrestaShop.php +++ b/src/Provider/PrestaShop.php @@ -61,6 +61,11 @@ class PrestaShop extends AbstractProvider */ protected $wellKnown; + /** + * @var CachedFile + */ + protected $cachedWellKnown; + /** * @var bool */ @@ -91,10 +96,15 @@ public function getOauth2Url() public function getWellKnown() { /* @phpstan-ignore-next-line */ - if (!isset($this->wellKnown)) { + if (!isset($this->wellKnown) || $this->cachedWellKnown->isExpired()) { try { $this->wellKnown = new WellKnown( - $this->fetchWellKnown($this->getOauth2Url(), $this->verify) + json_decode( + $this->cachedWellKnown ? + $this->getCachedWellKnown() : + $this->fetchWellKnown($this->getOauth2Url()), + true + ) ); } catch (\Throwable $e) { /* @phpstan-ignore-next-line */ @@ -109,14 +119,33 @@ public function getWellKnown() } /** - * @param string $url - * @param bool $secure + * @param bool $forceRefresh * - * @return array + * @return string * * @throws \Exception */ - protected function fetchWellKnown($url, $secure = true) + protected function getCachedWellKnown($forceRefresh = false) + { + if (null === $this->cachedWellKnown) { + throw new \Exception('Cache file not configured'); + } + + if ($this->cachedWellKnown->isExpired() || $forceRefresh) { + $this->cachedWellKnown->write( + $this->fetchWellKnown($this->getOauth2Url()) + ); + } + + return $this->cachedWellKnown->read(); + } + + /** + * @param string $url + * + * @return string + */ + protected function fetchWellKnown($url) { $wellKnownUrl = $url; if (strpos($wellKnownUrl, '/.well-known') === false) { @@ -125,7 +154,7 @@ protected function fetchWellKnown($url, $secure = true) $response = $this->getResponse($this->getRequest('GET', $wellKnownUrl)); - return json_decode($response->getBody(), true); + return (string) $response->getBody(); } /** diff --git a/tests/src/Provider/PrestaShopTest.php b/tests/src/Provider/PrestaShopTest.php index 3344db2..3c00f27 100644 --- a/tests/src/Provider/PrestaShopTest.php +++ b/tests/src/Provider/PrestaShopTest.php @@ -2,9 +2,9 @@ namespace PrestaShop\OAuth2\Client\Test\Provider; -use GuzzleHttp\ClientInterface; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Token\AccessToken; +use PrestaShop\OAuth2\Client\Provider\CachedFile; use PrestaShop\OAuth2\Client\Provider\PrestaShop; use PrestaShop\OAuth2\Client\Provider\PrestaShopUser; use PrestaShop\OAuth2\Client\Provider\WellKnown; @@ -13,34 +13,124 @@ class PrestaShopTest extends TestCase { /** - * @var PrestaShop + * @var CachedFile */ - private $provider; + private $cachedOpenIdConfiguration; + + /** + * @var string + */ + private $wellKnown = <<provider = $this->getMockBuilder(PrestaShop::class) - ->setConstructorArgs([[ - 'clientId' => 'test-client', - 'clientSecret' => 'secret', - 'redirectUri' => 'https://test-client-redirect.net', - 'uiLocales' => ['fr-CA', 'en'], - 'acrValues' => ['prompt:login'], - ]]) - ->setMethods(['getWellKnown']) - ->getMock(); - - $oauthUrl = 'https://oauth.foo.bar'; - - $this->provider->method('getWellKnown') - ->willReturn(new WellKnown([ - 'authorization_endpoint' => $oauthUrl . '/oauth2/auth', - 'token_endpoint' => $oauthUrl . '/oauth2/token', - 'userinfo_endpoint' => $oauthUrl . '/userinfo', - ])); + // $this->cachedJwks = new CachedFile($this->getTestBaseDir() . '/var/cache/jwks.json'); + $this->cachedOpenIdConfiguration = new CachedFile( + $this->getTestBaseDir() . '/var/cache/openid-configuration.json', 15 * 60 + ); + + $this->provider = new PrestaShop([ + 'clientId' => 'test-client', + 'clientSecret' => 'secret', + 'redirectUri' => 'https://test-client-redirect.net', + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, + 'uiLocales' => ['fr-CA', 'en'], + 'acrValues' => ['prompt:login'], + ]); + + $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); + $this->cachedOpenIdConfiguration->clear(); + $this->initHttpClient(); + } + + /** + * @test + */ + public function itShouldNotFailIfCachedFileNotConfigured() + { + $this->provider = new PrestaShop([ + 'clientId' => 'test-client', + 'clientSecret' => 'secret', + 'redirectUri' => 'https://test-client-redirect.net', + // 'cachedWellKnown' => $this->cachedOpenIdConfiguration, + 'uiLocales' => ['fr-CA', 'en'], + 'acrValues' => ['prompt:login'], + ]); + $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); + $this->initHttpClient(); + + $this->assertInstanceOf(WellKnown::class, $this->provider->getWellKnown()); + + $this->assertFalse(file_exists($this->cachedOpenIdConfiguration->getFilename())); + } + + /** + * @test + */ + public function itShouldStoreCachedOpenIdConfiguration() + { + $this->assertFalse(file_exists($this->cachedOpenIdConfiguration->getFilename())); + + $this->assertInstanceOf(WellKnown::class, $this->provider->getWellKnown()); + + $this->assertTrue(file_exists($this->cachedOpenIdConfiguration->getFilename())); + } + + /** + * @test + */ + public function itShouldRefreshCachedOpenIdConfiguration() + { + $this->cachedOpenIdConfiguration = new CachedFile( + $this->getTestBaseDir() . '/var/cache/openid-configuration.json', 1 + ); + + $this->provider = new PrestaShop([ + 'clientId' => 'test-client', + 'clientSecret' => 'secret', + 'redirectUri' => 'https://test-client-redirect.net', + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, + 'uiLocales' => ['fr-CA', 'en'], + 'acrValues' => ['prompt:login'], + ]); + $this->cachedOpenIdConfiguration->clear(); + $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); + $this->initHttpClient(); + + $openIdConfiguration = $this->provider->getWellKnown(); + + $this->assertFalse($this->cachedOpenIdConfiguration->isExpired()); + $this->assertInstanceOf(WellKnown::class, $openIdConfiguration); + $this->assertEquals('https://oauth.foo.bar/oauth2/auth', $openIdConfiguration->authorization_endpoint); + + usleep(2000000); + + $this->assertTrue($this->cachedOpenIdConfiguration->isExpired()); + + $this->wellKnownResponse = $this->createMockResponse(<<provider->getWellKnown(); + + $this->assertInstanceOf(WellKnown::class, $openIdConfiguration); + $this->assertEquals('https://oauth-refreshed.foo.bar/oauth2/auth', $openIdConfiguration->authorization_endpoint); } /** @@ -103,7 +193,7 @@ public function itShouldGetAuthorizationUrl() */ public function itShouldGetAccessTokenWithAuthorizationCode() { - $response = $this->createMockResponse(<<accessTokenResponse = $this->createMockResponse(<<createMock(ClientInterface::class); - $client->method('send') - ->willReturn($response); - - $this->provider->setHttpClient($client); - $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); $this->assertEquals('mock_access_token', $token->getToken()); @@ -134,7 +218,7 @@ public function itShouldGetAccessTokenWithAuthorizationCode() */ public function itShouldGetAccessTokenWithClientCredentials() { - $response = $this->createMockResponse(<<accessTokenResponse = $this->createMockResponse(<<createMock(ClientInterface::class); - $client->method('send') - ->withConsecutive([]) - ->willReturn($response); - - $this->provider->setHttpClient($client); $token = $this->provider->getAccessToken('client_credentials'); @@ -164,7 +242,7 @@ public function itShouldGetAccessTokenWithClientCredentials() */ public function itShouldGetResourceOwner() { - $response = $this->createMockResponse(<<resourceOwnerResponse = $this->createMockResponse(<<createMock(ClientInterface::class); - $client->method('send') - ->willReturn($response); - - $this->provider->setHttpClient($client); - $accessToken = $this->createMock(AccessToken::class); $accessToken->method('getToken') ->willReturn('mock_access_token'); @@ -201,7 +273,7 @@ public function itShouldGetResourceOwner() */ public function itShouldHandleErrors() { - $response = $this->createMockResponse(<<accessTokenResponse = $this->createMockResponse(<<createMock(ClientInterface::class); - $client->method('send') - ->willReturn($response); - - $this->provider->setHttpClient($client); - $this->expectException(IdentityProviderException::class); $this->expectExceptionMessage('403 - error_name: This is the description'); $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); @@ -225,13 +291,7 @@ public function itShouldHandleErrors() */ public function itShouldHandleEmptyErrors() { - $response = $this->createMockResponse('{}', 403); - - $client = $this->createMock(ClientInterface::class); - $client->method('send') - ->willReturn($response); - - $this->provider->setHttpClient($client); + $this->accessTokenResponse = $this->createMockResponse('{}', 403); $this->expectException(IdentityProviderException::class); $this->expectExceptionMessage('403 - : '); diff --git a/tests/src/Provider/Traits/LogoutTraitTest.php b/tests/src/Provider/Traits/LogoutTraitTest.php index d6884cc..ce9a511 100644 --- a/tests/src/Provider/Traits/LogoutTraitTest.php +++ b/tests/src/Provider/Traits/LogoutTraitTest.php @@ -2,29 +2,52 @@ namespace PrestaShop\OAuth2\Client\Test\Provider\Traits; -use PHPUnit\Framework\TestCase; +use PrestaShop\OAuth2\Client\Provider\CachedFile; use PrestaShop\OAuth2\Client\Provider\PrestaShop; +use PrestaShop\OAuth2\Client\Test\TestCase; class LogoutTraitTest extends TestCase { /** - * @var PrestaShop + * @var CachedFile */ - private $provider; + private $cachedOpenIdConfiguration; + + /** + * @var string + */ + private $wellKnown = <<cachedOpenIdConfiguration = new CachedFile( + $this->getTestBaseDir() . '/var/cache/openid-configuration.json', 15 * 60 + ); + $this->provider = new PrestaShop([ 'clientId' => 'test-client', 'clientSecret' => 'secret', 'redirectUri' => 'https://test-client-redirect.net', + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, 'postLogoutCallbackUri' => 'https://test-client-redirect.net/logout?oauth2Callback', 'uiLocales' => ['fr-CA', 'en'], 'acrValues' => ['prompt:login'], ]); + + $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); + $this->cachedOpenIdConfiguration->clear(); + $this->initHttpClient(); } /** @@ -85,6 +108,7 @@ public function itShouldGenerateLogoutUrlWithOptionalOnlyParameters() 'clientId' => 'test-client', 'clientSecret' => 'secret', 'redirectUri' => 'https://test-client-redirect.net', + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, // 'postLogoutCallbackUri' => 'https://test-client-redirect.net/logout?oauth2Callback', 'uiLocales' => ['fr-CA', 'en'], 'acrValues' => ['prompt:login'], @@ -167,6 +191,7 @@ public function itShouldThrowExceptionWhenPostLogoutCallbackUriIsMissing() 'clientId' => 'test-client', 'clientSecret' => 'secret', 'redirectUri' => 'https://test-client-redirect.net', + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, // 'postLogoutCallbackUri' => 'https://test-client-redirect.net/logout?oauth2Callback', 'uiLocales' => ['fr-CA', 'en'], 'acrValues' => ['prompt:login'], diff --git a/tests/src/Provider/Traits/TokenValidatorTraitTest.php b/tests/src/Provider/Traits/TokenValidatorTraitTest.php index 3b1d8e5..8084fcc 100644 --- a/tests/src/Provider/Traits/TokenValidatorTraitTest.php +++ b/tests/src/Provider/Traits/TokenValidatorTraitTest.php @@ -3,8 +3,6 @@ namespace PrestaShop\OAuth2\Client\Test\Provider\Traits; use Firebase\JWT\JWT; -use GuzzleHttp\ClientInterface; -use PrestaShop\Module\PsAccounts\Vendor\Psr\Http\Message\RequestInterface; use PrestaShop\OAuth2\Client\Provider\CachedFile; use PrestaShop\OAuth2\Client\Provider\Exception\AudienceInvalidException; use PrestaShop\OAuth2\Client\Provider\Exception\KidInvalidException; @@ -12,26 +10,28 @@ use PrestaShop\OAuth2\Client\Provider\Exception\SignatureInvalidException; use PrestaShop\OAuth2\Client\Provider\PrestaShop; use PrestaShop\OAuth2\Client\Test\TestCase; -use Psr\Http\Message\ResponseInterface; class TokenValidatorTraitTest extends TestCase { /** - * @var PrestaShop + * @var CachedFile */ - private $provider; + private $cachedJwks; /** * @var CachedFile */ - private $cachedFile; + private $cachedOpenIdConfiguration; + /** + * @var string + */ private $wellKnown = <<cachedFile = new CachedFile(__DIR__ . '/../../../var/cache/jwks.json'); + $this->cachedJwks = new CachedFile($this->getTestBaseDir() . '/var/cache/jwks.json'); + $this->cachedOpenIdConfiguration = new CachedFile( + $this->getTestBaseDir() . '/var/cache/openid-configuration.json', 15 * 60 + ); + $this->provider = new PrestaShop([ 'clientId' => 'test-client', 'clientSecret' => 'secret', 'redirectUri' => 'https://test-client-redirect.net', - 'cachedJwks' => $this->cachedFile, + 'cachedJwks' => $this->cachedJwks, + 'cachedWellKnown' => $this->cachedOpenIdConfiguration, 'uiLocales' => ['fr-CA', 'en'], 'acrValues' => ['prompt:login'], ]); @@ -163,7 +158,8 @@ protected function setUp(): void $this->wellKnownResponse = $this->createMockResponse($this->wellKnown); $this->jwksResponse = $this->createMockResponse($this->jwks); - $this->cachedFile->clear(); + $this->cachedJwks->clear(); + $this->cachedOpenIdConfiguration->clear(); $this->initHttpClient(); } @@ -191,11 +187,11 @@ public function itShouldFailIfCachedFileNotConfigured() */ public function itShouldStoreCachedJwks() { - $this->assertFalse(file_exists($this->cachedFile->getFilename())); + $this->assertFalse(file_exists($this->cachedJwks->getFilename())); $this->provider->getJwks(); - $this->assertTrue(file_exists($this->cachedFile->getFilename())); + $this->assertTrue(file_exists($this->cachedJwks->getFilename())); } /** @@ -368,23 +364,4 @@ private function encodeToken(array $payload, $privateKey = null, $kid = 'public: return JWT::encode($payload, $privateKey, 'RS256', $kid); } - - /** - * @return void - */ - private function initHttpClient() - { - $client = $this->createMock(ClientInterface::class); - $client->method('send') - ->willReturnCallback(function ($request) { - /** @var RequestInterface $request */ - if (preg_match('/jwks\.json$/', $request->getUri())) { - return $this->jwksResponse; - } - if (preg_match('/openid\-configuration/', $request->getUri())) { - return $this->wellKnownResponse; - } - }); - $this->provider->setHttpClient($client); - } } diff --git a/tests/src/TestCase.php b/tests/src/TestCase.php index b25d3ef..c38915f 100644 --- a/tests/src/TestCase.php +++ b/tests/src/TestCase.php @@ -2,11 +2,39 @@ namespace PrestaShop\OAuth2\Client\Test; +use GuzzleHttp\ClientInterface; use GuzzleHttp\Psr7\Utils; +use PrestaShop\OAuth2\Client\Provider\PrestaShop; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; class TestCase extends \PHPUnit\Framework\TestCase { + /** + * @var PrestaShop + */ + protected $provider; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|ResponseInterface|(ResponseInterface&\PHPUnit_Framework_MockObject_MockObject) + */ + protected $wellKnownResponse; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|ResponseInterface|(ResponseInterface&\PHPUnit_Framework_MockObject_MockObject) + */ + protected $accessTokenResponse; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|ResponseInterface|(ResponseInterface&\PHPUnit_Framework_MockObject_MockObject) + */ + protected $resourceOwnerResponse; + + /** + * @var \PHPUnit_Framework_MockObject_MockObject|(\PHPUnit_Framework_MockObject_MockObject&ResponseInterface)|ResponseInterface + */ + protected $jwksResponse; + /** * @param $responseBody * @param $statusCode @@ -29,4 +57,37 @@ protected function createMockResponse($responseBody, $statusCode = 200) return $response; } + + /** + * @return void + */ + protected function initHttpClient() + { + $client = $this->createMock(ClientInterface::class); + $client->method('send') + ->willReturnCallback(function ($request) { + /** @var RequestInterface $request */ + if (preg_match('/jwks\.json$/', $request->getUri())) { + return $this->jwksResponse; + } + if (preg_match('/openid\-configuration/', $request->getUri())) { + return $this->wellKnownResponse; + } + if (preg_match('/oauth2\/token/', $request->getUri())) { + return $this->accessTokenResponse; + } + if (preg_match('/userinfo/', $request->getUri())) { + return $this->resourceOwnerResponse; + } + }); + $this->provider->setHttpClient($client); + } + + /** + * @return string + */ + protected function getTestBaseDir() + { + return __DIR__ . DIRECTORY_SEPARATOR . '..'; + } } From 6147b146847cbb16b21ca4ea7e9d824532e71766 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Fri, 6 Dec 2024 12:08:55 +0100 Subject: [PATCH 07/14] feat: cached openid-configuration unit tests enhancements & fully local --- src/Provider/PrestaShop.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Provider/PrestaShop.php b/src/Provider/PrestaShop.php index 2ca5cad..f394ed4 100644 --- a/src/Provider/PrestaShop.php +++ b/src/Provider/PrestaShop.php @@ -100,7 +100,7 @@ public function getWellKnown() try { $this->wellKnown = new WellKnown( json_decode( - $this->cachedWellKnown ? + ($this->cachedWellKnown !== null) ? $this->getCachedWellKnown() : $this->fetchWellKnown($this->getOauth2Url()), true From a95785984de352847435951d9a443d086a91e567 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Fri, 6 Dec 2024 14:16:34 +0100 Subject: [PATCH 08/14] chore: update README --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 7fc7256..d3660e0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ $prestaShopProvider = new \PrestaShop\OAuth2\Client\Provider\PrestaShop([ 'clientSecret' => 'yourClientSecret', // The client password assigned to you by PrestaShop 'redirectUri' => 'yourClientRedirectUri', // The URL responding to the code flow implemented here // Optional parameters + 'cachedWellKnown' => new CachedFile( + __DIR__ . '/var/cache/openid-configuration.json', 15 * 60 + ), 'uiLocales' => ['fr-FR', 'en'], 'acrValues' => ['prompt:create'], // In that specific case we change the default prompt to the "register" page ]); @@ -117,6 +120,9 @@ $prestaShopProvider = new \PrestaShop\OAuth2\Client\Provider\PrestaShop([ 'redirectUri' => 'yourClientRedirectUri', // The URL responding to the code flow implemented here 'postLogoutCallbackUri' => 'yourLogoutCallbackUri', // Logout url whitelisted among the ones defined with your client // Optional parameters + 'cachedWellKnown' => new CachedFile( + __DIR__ . '/var/cache/openid-configuration.json', 15 * 60 + ), 'uiLocales' => ['fr-FR', 'en'], 'acrValues' => ['prompt:create'], // In that specific case we change the default prompt to the "register" page ]); @@ -142,6 +148,45 @@ if (isset($_GET['oauth2Callback')) { } ``` +## Token Validation + +```php +$prestaShopProvider = new \PrestaShop\OAuth2\Client\Provider\PrestaShop([ + 'clientId' => 'yourClientId', // The client ID assigned to you by PrestaShop + 'clientSecret' => 'yourClientSecret', // The client password assigned to you by PrestaShop + 'redirectUri' => 'yourClientRedirectUri', // The URL responding to the code flow implemented here + // Optional parameters + 'cachedJwks' => new CachedFile(__DIR__ . '/var/cache/jwks.json'), + 'cachedWellKnown' => new CachedFile( + __DIR__ . '/var/cache/openid-configuration.json', 15 * 60 + ), + 'uiLocales' => ['fr-FR', 'en'], + 'acrValues' => ['prompt:create'], // In that specific case we change the default prompt to the "register" page +]); + +try { + // Only verifying a token + $prestaShopProvider->verifyToken($jwtString); +} catch (SignatureInvalidException) { +} catch (TokenExpiredException) { +} catch (TokenInvalidException) { +} + +try { + // Verifying and checking required scope(s) and audience(s) + $prestaShopProvider->validateToken( + $jwtString, + ['resource.read', 'resource.wrire'], + ['https://an-audience'] + ); +} catch (SignatureInvalidException) { +} catch (TokenExpiredException) { +} catch (ScopeInvalidException) { +} catch (AudienceInvalidException) { +} catch (TokenInvalidException) { +} +``` + ## Testing ``` bash From 35f8663e0c3c7504aa01f0ad76f0de0485ca74aa Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 09:13:36 +0100 Subject: [PATCH 09/14] refactor: extract methods for validating scopes & audience separately --- src/Provider/Traits/TokenValidatorTrait.php | 32 ++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index bb505f4..8efad5d 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -92,19 +92,43 @@ public function verifyToken($token, $refreshJwks = false) public function validateToken($token, array $scope = [], array $audience = []) { $token = $this->verifyToken($token); + $this->validateScope($token, $scope); + $this->validateAudience($token, $audience); + return $token; + } + + /** + * @param object $token + * @param array $scope + * + * @return void + * + * @throws Exception\ScopeInvalidException + */ + public function validateScope($token, array $scope) + { // check expected scopes are included $scp = is_array($token->scp) ? array_unique($token->scp) : []; if (count(array_intersect($scope, $scp)) < count($scope)) { - throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scope)); + throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scp)); } + } + /** + * @param object $token + * @param array $audience + * + * @return void + * + * @throws Exception\AudienceInvalidException + */ + public function validateAudience($token, array $audience) + { // check expected audiences are included $aud = is_array($token->aud) ? array_unique($token->aud) : []; if (count(array_intersect($audience, $aud)) < count($audience)) { - throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $audience)); + throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $aud)); } - - return $token; } } From dc720bd51f5e10b4be586ca23d2385fab0a623c9 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 09:37:46 +0100 Subject: [PATCH 10/14] refactor: extract methods for validating scopes & audience separately --- src/Provider/Traits/TokenValidatorTrait.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index 8efad5d..3c7d7ad 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -111,7 +111,7 @@ public function validateScope($token, array $scope) // check expected scopes are included $scp = is_array($token->scp) ? array_unique($token->scp) : []; if (count(array_intersect($scope, $scp)) < count($scope)) { - throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scp)); + throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scope)); } } @@ -128,7 +128,7 @@ public function validateAudience($token, array $audience) // check expected audiences are included $aud = is_array($token->aud) ? array_unique($token->aud) : []; if (count(array_intersect($audience, $aud)) < count($audience)) { - throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $aud)); + throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $audience)); } } } From b8650972ada69209eff715c3c007fcdd21b9e2ca Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 09:46:22 +0100 Subject: [PATCH 11/14] refactor: extract methods for validating scopes & audience separately --- src/Provider/Traits/TokenValidatorTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index 3c7d7ad..dac05a2 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -92,8 +92,8 @@ public function verifyToken($token, $refreshJwks = false) public function validateToken($token, array $scope = [], array $audience = []) { $token = $this->verifyToken($token); - $this->validateScope($token, $scope); $this->validateAudience($token, $audience); + $this->validateScope($token, $scope); return $token; } From 6fe08301cb890069b1341ffb3675424010e07153 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 09:52:43 +0100 Subject: [PATCH 12/14] refactor: extract methods for validating scopes & audience separately --- src/Provider/Traits/TokenValidatorTrait.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index dac05a2..f834336 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -111,7 +111,7 @@ public function validateScope($token, array $scope) // check expected scopes are included $scp = is_array($token->scp) ? array_unique($token->scp) : []; if (count(array_intersect($scope, $scp)) < count($scope)) { - throw new Exception\ScopeInvalidException('Expected scopes not matched: ' . implode(', ', $scope)); + throw new Exception\ScopeInvalidException('Expected scope not matched: ' . implode(', ', $scope)); } } @@ -128,7 +128,7 @@ public function validateAudience($token, array $audience) // check expected audiences are included $aud = is_array($token->aud) ? array_unique($token->aud) : []; if (count(array_intersect($audience, $aud)) < count($audience)) { - throw new Exception\AudienceInvalidException('Expected audiences not matched: ' . implode(', ', $audience)); + throw new Exception\AudienceInvalidException('Expected audience not matched: ' . implode(', ', $audience)); } } } From 7844c0c230d30b0a34715ae5ce7f2451c780dc31 Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 14:06:47 +0100 Subject: [PATCH 13/14] refactor: extract methods for validating scopes & audience separately --- src/Provider/Traits/TokenValidatorTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index f834336..4924e96 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -65,7 +65,7 @@ public function verifyToken($token, $refreshJwks = false) if (!$refreshJwks && $e->getMessage() == '"kid" invalid, unable to lookup correct key') { return $this->verifyToken($token, true); } - throw new KidInvalidException($e->getMessage()); + throw new Exception\TokenInvalidException($e->getMessage()); } catch (\Throwable $e) { throw new Exception\TokenInvalidException($e->getMessage()); /* @phpstan-ignore-next-line */ From cf00c5d373c8b362a8a022d40e861c7aaecf593b Mon Sep 17 00:00:00 2001 From: hschoenenberger Date: Tue, 10 Dec 2024 14:12:26 +0100 Subject: [PATCH 14/14] fix: invalid kid exception handling --- src/Provider/Traits/TokenValidatorTrait.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Provider/Traits/TokenValidatorTrait.php b/src/Provider/Traits/TokenValidatorTrait.php index 4924e96..e054562 100644 --- a/src/Provider/Traits/TokenValidatorTrait.php +++ b/src/Provider/Traits/TokenValidatorTrait.php @@ -8,7 +8,6 @@ use Firebase\JWT\SignatureInvalidException; use PrestaShop\OAuth2\Client\Provider\CachedFile; use PrestaShop\OAuth2\Client\Provider\Exception; -use PrestaShop\OAuth2\Client\Provider\Exception\KidInvalidException; trait TokenValidatorTrait { @@ -62,8 +61,11 @@ public function verifyToken($token, $refreshJwks = false) throw new Exception\SignatureInvalidException($e->getMessage()); } catch (\UnexpectedValueException $e) { // FIXME: check kid header by ourselves - if (!$refreshJwks && $e->getMessage() == '"kid" invalid, unable to lookup correct key') { - return $this->verifyToken($token, true); + if ($e->getMessage() == '"kid" invalid, unable to lookup correct key') { + if (!$refreshJwks) { + return $this->verifyToken($token, true); + } + throw new Exception\KidInvalidException($e->getMessage()); } throw new Exception\TokenInvalidException($e->getMessage()); } catch (\Throwable $e) {