diff --git a/CHANGELOG.md b/CHANGELOG.md index 66be4db6..a4b655d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.6.0] - 2024-10-10 + +### Added + +- Added hash of clientId, scopes and clientSecret to cacheKey of AccessToken + ## [2.5.0] - 2024-08-13 ### Added @@ -19,7 +25,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - Support for `preselected` provider selection under the Bank Transfer payment method -- Support for `preselected` payment scheme under the preselected provider selection +- Support for `preselected` payment scheme under the preselected provider selection ## [2.3.0] - 2024-07-18 diff --git a/src/Services/Auth/AccessToken.php b/src/Services/Auth/AccessToken.php index 5c63d436..4ffb3e4a 100644 --- a/src/Services/Auth/AccessToken.php +++ b/src/Services/Auth/AccessToken.php @@ -56,6 +56,11 @@ final class AccessToken implements AccessTokenInterface */ private ?int $retrievedAt = null; + /** + * @var string|null + */ + private ?string $cacheSuffix = null; + /** * @param ApiClientInterface $api * @param EncryptedCacheInterface|null $cache @@ -85,9 +90,9 @@ public function __construct(ApiClientInterface $api, public function getAccessToken(): ?string { if (!$this->accessToken) { - if ($this->cache && $this->cache->has(CacheKeys::AUTH_TOKEN)) { + if ($this->cache && $this->cache->has($this->getCacheKey())) { /** @var array{access_token: string, expires_in: int, retrieved_at: int} $data */ - $data = $this->cache->get(CacheKeys::AUTH_TOKEN); + $data = $this->cache->get($this->getCacheKey()); $this->accessToken = $data['access_token']; $this->expiresIn = $data['expires_in']; @@ -161,8 +166,28 @@ private function retrieve(): void $this->retrievedAt = (int) Carbon::now()->timestamp; if ($this->cache) { - $this->cache->set(CacheKeys::AUTH_TOKEN, $this->toArray(), $this->getExpiresIn()); + $this->cache->set($this->getCacheKey(), $this->toArray(), $this->getExpiresIn()); + } + } + + /** + * @return string + */ + private function getCacheKey(): string + { + return CacheKeys::AUTH_TOKEN . ':' . $this->getCacheSuffix(); + } + + /** + * @return string + */ + private function getCacheSuffix(): string + { + if (!$this->cacheSuffix) { + $this->cacheSuffix = \hash_hmac('sha256', \implode(',', [$this->clientId, ...$this->scopes]), $this->clientSecret); } + + return $this->cacheSuffix; } /** diff --git a/tests/integration/ApiClient/AccessTokenTest.php b/tests/integration/ApiClient/AccessTokenTest.php index 39fccd1e..3be20153 100644 --- a/tests/integration/ApiClient/AccessTokenTest.php +++ b/tests/integration/ApiClient/AccessTokenTest.php @@ -131,6 +131,150 @@ function () { \expect($fooRequest->getHeaderLine('Authorization'))->not()->toBe('Bearer expired-token'); }); +\it('uses different cache key if client id is changed', function () { + $okResponse = new Response(200); + $encrypter = new TrueLayer\Services\Util\Encryption\Encrypter(\hex2bin('31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b'), TrueLayer\Constants\Encryption::ALGORITHM); + $encryptedAccessToken = $encrypter->encrypt([ + 'access_token' => Mocks\AuthResponse::ACCESS_TOKEN, + 'expires_in' => 3600, + 'retrieved_at' => (int) Carbon::now()->timestamp, + ]); + + $cacheMock1 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock1->shouldReceive('has')->andReturnTrue(); + $cacheMock1->shouldReceive('set')->andReturnTrue(); + $cacheMock1->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client1 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock1, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->clientId('client_id_1') + ->create() + ->getApiClient(); + $client1->request()->uri('/foo')->post(); + + $cacheKey1 = null; + $cacheDefaultValue = 'not-null'; + + $cacheMock1->shouldHaveReceived('get', function (...$args) use (&$cacheKey1, &$cacheDefaultValue) { + $cacheKey1 = $args[0]; + $cacheDefaultValue = $args[1]; + return true; + }); + \expect($cacheKey1)->toBeString(); + \expect($cacheKey1)->not()->toBeEmpty(); + \expect($cacheDefaultValue)->toBeNull(); + + $cacheMock2 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock2->shouldReceive('has')->andReturnTrue(); + $cacheMock2->shouldReceive('set')->andReturnTrue(); + $cacheMock2->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client2 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock2, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->clientId('client_id_2') + ->create() + ->getApiClient(); + $client2->request()->uri('/foo')->post(); + + $cacheMock2->shouldNotHaveReceived('get', [$cacheKey1, $cacheDefaultValue]); +}); + +\it('uses different cache key if client secret is changed', function () { + $okResponse = new Response(200); + $encrypter = new TrueLayer\Services\Util\Encryption\Encrypter(\hex2bin('31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b'), TrueLayer\Constants\Encryption::ALGORITHM); + $encryptedAccessToken = $encrypter->encrypt([ + 'access_token' => Mocks\AuthResponse::ACCESS_TOKEN, + 'expires_in' => 3600, + 'retrieved_at' => (int) Carbon::now()->timestamp, + ]); + + $cacheMock1 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock1->shouldReceive('has')->andReturnTrue(); + $cacheMock1->shouldReceive('set')->andReturnTrue(); + $cacheMock1->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client1 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock1, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->clientSecret('client_secret_1') + ->create() + ->getApiClient(); + $client1->request()->uri('/foo')->post(); + + $cacheKey1 = null; + $cacheDefaultValue = 'not-null'; + + $cacheMock1->shouldHaveReceived('get', function (...$args) use (&$cacheKey1, &$cacheDefaultValue) { + $cacheKey1 = $args[0]; + $cacheDefaultValue = $args[1]; + return true; + }); + \expect($cacheKey1)->toBeString(); + \expect($cacheKey1)->not()->toBeEmpty(); + \expect($cacheDefaultValue)->toBeNull(); + + $cacheMock2 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock2->shouldReceive('has')->andReturnTrue(); + $cacheMock2->shouldReceive('set')->andReturnTrue(); + $cacheMock2->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client2 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock2, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->clientSecret('client_secret_2') + ->create() + ->getApiClient(); + $client2->request()->uri('/foo')->post(); + + $cacheMock2->shouldNotHaveReceived('get', [$cacheKey1, $cacheDefaultValue]); +}); + +\it('uses different cache key if scopes are changed', function () { + $okResponse = new Response(200); + $encrypter = new TrueLayer\Services\Util\Encryption\Encrypter(\hex2bin('31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b'), TrueLayer\Constants\Encryption::ALGORITHM); + $encryptedAccessToken = $encrypter->encrypt([ + 'access_token' => Mocks\AuthResponse::ACCESS_TOKEN, + 'expires_in' => 3600, + 'retrieved_at' => (int) Carbon::now()->timestamp, + ]); + + $cacheMock1 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock1->shouldReceive('has')->andReturnTrue(); + $cacheMock1->shouldReceive('set')->andReturnTrue(); + $cacheMock1->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client1 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock1, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->scopes('payments') + ->create() + ->getApiClient(); + $client1->request()->uri('/foo')->post(); + + $cacheKey1 = null; + $cacheDefaultValue = 'not-null'; + + $cacheMock1->shouldHaveReceived('get', function (...$args) use (&$cacheKey1, &$cacheDefaultValue) { + $cacheKey1 = $args[0]; + $cacheDefaultValue = $args[1]; + return true; + }); + \expect($cacheKey1)->toBeString(); + \expect($cacheKey1)->not()->toBeEmpty(); + \expect($cacheDefaultValue)->toBeNull(); + + $cacheMock2 = Mockery::mock(Psr\SimpleCache\CacheInterface::class); + $cacheMock2->shouldReceive('has')->andReturnTrue(); + $cacheMock2->shouldReceive('set')->andReturnTrue(); + $cacheMock2->shouldReceive('get')->andReturn($encryptedAccessToken); + + $client2 = \rawClient([Mocks\AuthResponse::success(), $okResponse, $okResponse]) + ->cache($cacheMock2, '31c8d81a110849f83131541b9f67c3cba9c7e0bb103bc4dd19377f0fdf2d924b') + ->scopes('payments', 'accounts') + ->create() + ->getApiClient(); + $client2->request()->uri('/foo')->post(); + + $cacheMock2->shouldNotHaveReceived('get', [$cacheKey1, $cacheDefaultValue]); +}); + \it('uses default scope if none provided', function () { $client = \rawClient([Mocks\AuthResponse::success(), new Response(200)])->create(); $client->getApiClient()->request()->uri('/test')->post();