From f0ee70dafd7b58124280621e47debda0c23cdbc9 Mon Sep 17 00:00:00 2001 From: Jan Hopman Date: Mon, 8 Feb 2021 17:16:10 +0100 Subject: [PATCH 1/9] Add switch to prevent revoking of refresh tokens. --- src/AuthorizationServer.php | 11 ++++++++++- src/Grant/AbstractGrant.php | 17 +++++++++++++++-- src/Grant/GrantTypeInterface.php | 7 +++++++ src/Grant/RefreshTokenGrant.php | 14 +++++++++----- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index 8b0b2815e..90de52578 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -79,6 +79,11 @@ class AuthorizationServer implements EmitterAwareInterface */ private $defaultScope = ''; + /** + * @var bool + */ + private $revokeRefreshTokens; + /** * New server instance. * @@ -88,6 +93,7 @@ class AuthorizationServer implements EmitterAwareInterface * @param CryptKey|string $privateKey * @param string|Key $encryptionKey * @param null|ResponseTypeInterface $responseType + * @param bool $revokeRefreshTokens */ public function __construct( ClientRepositoryInterface $clientRepository, @@ -95,7 +101,8 @@ public function __construct( ScopeRepositoryInterface $scopeRepository, $privateKey, $encryptionKey, - ResponseTypeInterface $responseType = null + ResponseTypeInterface $responseType = null, + bool $revokeRefreshTokens = true ) { $this->clientRepository = $clientRepository; $this->accessTokenRepository = $accessTokenRepository; @@ -115,6 +122,7 @@ public function __construct( } $this->responseType = $responseType; + $this->revokeRefreshTokens = $revokeRefreshTokens; } /** @@ -136,6 +144,7 @@ public function enableGrantType(GrantTypeInterface $grantType, DateInterval $acc $grantType->setPrivateKey($this->privateKey); $grantType->setEmitter($this->getEmitter()); $grantType->setEncryptionKey($this->encryptionKey); + $grantType->setRevokeRefreshTokens($this->revokeRefreshTokens); $this->enabledGrantTypes[$grantType->getIdentifier()] = $grantType; $this->grantTypeAccessTokenTTL[$grantType->getIdentifier()] = $accessTokenTTL; diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index c4797292a..c82286ead 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -92,6 +92,11 @@ abstract class AbstractGrant implements GrantTypeInterface */ protected $defaultScope; + /** + * @var bool + */ + protected $revokeRefreshTokens; + /** * @param ClientRepositoryInterface $clientRepository */ @@ -166,6 +171,14 @@ public function setDefaultScope($scope) $this->defaultScope = $scope; } + /** + * @param bool $revokeRefreshTokens + */ + public function setRevokeRefreshTokens(bool $revokeRefreshTokens) + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } + /** * Validate the client. * @@ -177,7 +190,7 @@ public function setDefaultScope($scope) */ protected function validateClient(ServerRequestInterface $request) { - list($clientId, $clientSecret) = $this->getClientCredentials($request); + [$clientId, $clientSecret] = $this->getClientCredentials($request); if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) { $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); @@ -234,7 +247,7 @@ protected function getClientEntityOrFail($clientId, ServerRequestInterface $requ */ protected function getClientCredentials(ServerRequestInterface $request) { - list($basicAuthUser, $basicAuthPassword) = $this->getBasicAuthCredentials($request); + [$basicAuthUser, $basicAuthPassword] = $this->getBasicAuthCredentials($request); $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); diff --git a/src/Grant/GrantTypeInterface.php b/src/Grant/GrantTypeInterface.php index 41ebeb5ff..d761fb0ab 100644 --- a/src/Grant/GrantTypeInterface.php +++ b/src/Grant/GrantTypeInterface.php @@ -141,4 +141,11 @@ public function setPrivateKey(CryptKey $privateKey); * @param string|Key|null $key */ public function setEncryptionKey($key = null); + + /** + * Enables ability to prevent refresh tokens from being revoked. + * + * @param bool $revokeRefreshTokens + */ + public function setRevokeRefreshTokens(bool $revokeRefreshTokens); } diff --git a/src/Grant/RefreshTokenGrant.php b/src/Grant/RefreshTokenGrant.php index 19945f5ce..ea781f53b 100644 --- a/src/Grant/RefreshTokenGrant.php +++ b/src/Grant/RefreshTokenGrant.php @@ -63,7 +63,9 @@ public function respondToAccessTokenRequest( // Expire old tokens $this->accessTokenRepository->revokeAccessToken($oldRefreshToken['access_token_id']); - $this->refreshTokenRepository->revokeRefreshToken($oldRefreshToken['refresh_token_id']); + if ($this->revokeRefreshTokens) { + $this->refreshTokenRepository->revokeRefreshToken($oldRefreshToken['refresh_token_id']); + } // Issue and persist new access token $accessToken = $this->issueAccessToken($accessTokenTTL, $client, $oldRefreshToken['user_id'], $scopes); @@ -71,11 +73,13 @@ public function respondToAccessTokenRequest( $responseType->setAccessToken($accessToken); // Issue and persist new refresh token if given - $refreshToken = $this->issueRefreshToken($accessToken); + if ($this->revokeRefreshTokens) { + $refreshToken = $this->issueRefreshToken($accessToken); - if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); - $responseType->setRefreshToken($refreshToken); + if ($refreshToken !== null) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request)); + $responseType->setRefreshToken($refreshToken); + } } return $responseType; From fe20b334b4aed9be8eae20e708a846a51d4057d2 Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Thu, 11 Feb 2021 16:28:56 +0100 Subject: [PATCH 2/9] Fix unit test. --- tests/Grant/RefreshTokenGrantTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Grant/RefreshTokenGrantTest.php b/tests/Grant/RefreshTokenGrantTest.php index 48e81f619..f60b9a67d 100644 --- a/tests/Grant/RefreshTokenGrantTest.php +++ b/tests/Grant/RefreshTokenGrantTest.php @@ -68,6 +68,7 @@ public function testRespondToRequest() $grant->setAccessTokenRepository($accessTokenRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setRevokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -181,6 +182,7 @@ public function testRespondToReducedScopes() $grant->setScopeRepository($scopeRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setRevokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( From c8fb25eb3863526a3687449833ccd3ab75e74115 Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Mon, 31 May 2021 15:06:55 +0200 Subject: [PATCH 3/9] Empty commit From 8ea2bd12c87504c26e3895fe972387580948253a Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Mon, 31 May 2021 15:16:03 +0200 Subject: [PATCH 4/9] Fix ci style. --- src/Grant/RefreshTokenGrant.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Grant/RefreshTokenGrant.php b/src/Grant/RefreshTokenGrant.php index 3c3649303..2dedf15c3 100644 --- a/src/Grant/RefreshTokenGrant.php +++ b/src/Grant/RefreshTokenGrant.php @@ -79,7 +79,7 @@ public function respondToAccessTokenRequest( $refreshToken = $this->issueRefreshToken($accessToken); if ($refreshToken !== null) { - $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)); + $this->getEmitter()->emit(new RequestRefreshTokenEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request, $refreshToken)); $responseType->setRefreshToken($refreshToken); } } From 5cf9d0737d9575cfd2b1ef6fbc736a7b364e9a23 Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Mon, 31 May 2021 15:28:27 +0200 Subject: [PATCH 5/9] Remove method from interface since it is already present on the abstract. --- src/Grant/GrantTypeInterface.php | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/Grant/GrantTypeInterface.php b/src/Grant/GrantTypeInterface.php index d761fb0ab..41ebeb5ff 100644 --- a/src/Grant/GrantTypeInterface.php +++ b/src/Grant/GrantTypeInterface.php @@ -141,11 +141,4 @@ public function setPrivateKey(CryptKey $privateKey); * @param string|Key|null $key */ public function setEncryptionKey($key = null); - - /** - * Enables ability to prevent refresh tokens from being revoked. - * - * @param bool $revokeRefreshTokens - */ - public function setRevokeRefreshTokens(bool $revokeRefreshTokens); } From c808d13049b12bdf954049e45d388dfc8462f7ed Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Tue, 1 Jun 2021 09:24:35 +0200 Subject: [PATCH 6/9] Set revokeRefreshTokens prop to true by default, added method to change, cleaned up constructor. --- src/AuthorizationServer.php | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index 90de52578..1b6d593a7 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -82,7 +82,7 @@ class AuthorizationServer implements EmitterAwareInterface /** * @var bool */ - private $revokeRefreshTokens; + private $revokeRefreshTokens = true; /** * New server instance. @@ -93,7 +93,6 @@ class AuthorizationServer implements EmitterAwareInterface * @param CryptKey|string $privateKey * @param string|Key $encryptionKey * @param null|ResponseTypeInterface $responseType - * @param bool $revokeRefreshTokens */ public function __construct( ClientRepositoryInterface $clientRepository, @@ -101,8 +100,7 @@ public function __construct( ScopeRepositoryInterface $scopeRepository, $privateKey, $encryptionKey, - ResponseTypeInterface $responseType = null, - bool $revokeRefreshTokens = true + ResponseTypeInterface $responseType = null ) { $this->clientRepository = $clientRepository; $this->accessTokenRepository = $accessTokenRepository; @@ -122,7 +120,6 @@ public function __construct( } $this->responseType = $responseType; - $this->revokeRefreshTokens = $revokeRefreshTokens; } /** @@ -242,4 +239,14 @@ public function setDefaultScope($defaultScope) { $this->defaultScope = $defaultScope; } + + /** + * Sets wether to revoke refresh tokens or not (for all grant types). + * + * @param bool $revokeRefreshTokens + */ + public function setRevokeRefreshTokens(bool $revokeRefreshTokens): void + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } } From d0cf49242b16ce5620f7f781c32663605f16b33f Mon Sep 17 00:00:00 2001 From: janhopman-nhb Date: Tue, 1 Jun 2021 13:43:35 +0200 Subject: [PATCH 7/9] Add tests. --- tests/Grant/RefreshTokenGrantTest.php | 115 ++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/tests/Grant/RefreshTokenGrantTest.php b/tests/Grant/RefreshTokenGrantTest.php index f60b9a67d..090919c61 100644 --- a/tests/Grant/RefreshTokenGrantTest.php +++ b/tests/Grant/RefreshTokenGrantTest.php @@ -18,6 +18,7 @@ use LeagueTests\Stubs\RefreshTokenEntity; use LeagueTests\Stubs\ScopeEntity; use LeagueTests\Stubs\StubResponseType; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; class RefreshTokenGrantTest extends TestCase @@ -469,4 +470,118 @@ public function testRespondToRequestRevokedToken() $grant->respondToAccessTokenRequest($serverRequest, $responseType, new DateInterval('PT5M')); } + + public function testRevokedRefreshToken() + { + $refreshTokenId = 'foo'; + + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier('foo'); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->expects($this->once())->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('isRefreshTokenRevoked') + ->will($this->onConsecutiveCalls(false, true)); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken')->with($this->equalTo($refreshTokenId)); + + $oldRefreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => $refreshTokenId, + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'refresh_token' => $oldRefreshToken, + 'scope' => ['foo'], + ]); + + $grant = new RefreshTokenGrant($refreshTokenRepositoryMock); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setEncryptionKey($this->cryptStub->getKey()); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->setRevokeRefreshTokens(true); + $grant->respondToAccessTokenRequest($serverRequest, new StubResponseType(), new DateInterval('PT5M')); + + Assert::assertTrue($refreshTokenRepositoryMock->isRefreshTokenRevoked($refreshTokenId)); + } + + public function testUnrevokedRefreshToken() + { + $refreshTokenId = 'foo'; + + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo/bar'); + + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $scopeEntity = new ScopeEntity(); + $scopeEntity->setIdentifier('foo'); + + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scopeEntity); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + $accessTokenRepositoryMock->expects($this->once())->method('persistNewAccessToken')->willReturnSelf(); + + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->method('isRefreshTokenRevoked')->willReturn(false); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $oldRefreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => $refreshTokenId, + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = (new ServerRequest())->withParsedBody([ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'refresh_token' => $oldRefreshToken, + 'scope' => ['foo'], + ]); + + $grant = new RefreshTokenGrant($refreshTokenRepositoryMock); + $grant->setClientRepository($clientRepositoryMock); + $grant->setScopeRepository($scopeRepositoryMock); + $grant->setAccessTokenRepository($accessTokenRepositoryMock); + $grant->setEncryptionKey($this->cryptStub->getKey()); + $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->respondToAccessTokenRequest($serverRequest, new StubResponseType(), new DateInterval('PT5M')); + + Assert::assertFalse($refreshTokenRepositoryMock->isRefreshTokenRevoked($refreshTokenId)); + } } From d7634f92fcc2a4dc90a611987dc06579957b68d0 Mon Sep 17 00:00:00 2001 From: Andrew Millington Date: Thu, 3 Jun 2021 22:48:24 +0100 Subject: [PATCH 8/9] change function name and update changelog --- CHANGELOG.md | 1 + src/AuthorizationServer.php | 4 ++-- src/Grant/AbstractGrant.php | 2 +- tests/Grant/RefreshTokenGrantTest.php | 6 +++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7738c2f7..d42353c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - The server will now validate redirect uris according to rfc8252 (PR #1203) - Events emitted now include the refresh token and access token payloads (PR #1211) +- Use the `revokeRefreshTokens()` function to decide whether refresh tokens are revoked or not upon use (PR #1189) ### Changed - Keys are now validated using `openssl_pkey_get_private()` and openssl_pkey_get_public()` instead of regex matching (PR #1215) diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index 1b6d593a7..a719656c6 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -141,7 +141,7 @@ public function enableGrantType(GrantTypeInterface $grantType, DateInterval $acc $grantType->setPrivateKey($this->privateKey); $grantType->setEmitter($this->getEmitter()); $grantType->setEncryptionKey($this->encryptionKey); - $grantType->setRevokeRefreshTokens($this->revokeRefreshTokens); + $grantType->revokeRefreshTokens($this->revokeRefreshTokens); $this->enabledGrantTypes[$grantType->getIdentifier()] = $grantType; $this->grantTypeAccessTokenTTL[$grantType->getIdentifier()] = $accessTokenTTL; @@ -245,7 +245,7 @@ public function setDefaultScope($defaultScope) * * @param bool $revokeRefreshTokens */ - public function setRevokeRefreshTokens(bool $revokeRefreshTokens): void + public function revokeRefreshTokens(bool $revokeRefreshTokens): void { $this->revokeRefreshTokens = $revokeRefreshTokens; } diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index 8192da1a6..1665b980e 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -175,7 +175,7 @@ public function setDefaultScope($scope) /** * @param bool $revokeRefreshTokens */ - public function setRevokeRefreshTokens(bool $revokeRefreshTokens) + public function revokeRefreshTokens(bool $revokeRefreshTokens) { $this->revokeRefreshTokens = $revokeRefreshTokens; } diff --git a/tests/Grant/RefreshTokenGrantTest.php b/tests/Grant/RefreshTokenGrantTest.php index 090919c61..8f56fac4c 100644 --- a/tests/Grant/RefreshTokenGrantTest.php +++ b/tests/Grant/RefreshTokenGrantTest.php @@ -69,7 +69,7 @@ public function testRespondToRequest() $grant->setAccessTokenRepository($accessTokenRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); - $grant->setRevokeRefreshTokens(true); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -183,7 +183,7 @@ public function testRespondToReducedScopes() $grant->setScopeRepository($scopeRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); - $grant->setRevokeRefreshTokens(true); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -523,7 +523,7 @@ public function testRevokedRefreshToken() $grant->setAccessTokenRepository($accessTokenRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); - $grant->setRevokeRefreshTokens(true); + $grant->revokeRefreshTokens(true); $grant->respondToAccessTokenRequest($serverRequest, new StubResponseType(), new DateInterval('PT5M')); Assert::assertTrue($refreshTokenRepositoryMock->isRefreshTokenRevoked($refreshTokenId)); From 4ea27e85a8237086b7fa2be89c297d06f99e1535 Mon Sep 17 00:00:00 2001 From: Andrew Millington Date: Thu, 3 Jun 2021 22:55:03 +0100 Subject: [PATCH 9/9] update changelog for version 8.3.0 release --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42353c13..048479c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [8.3.0] - released 2021-06-03 ### Added - The server will now validate redirect uris according to rfc8252 (PR #1203) - Events emitted now include the refresh token and access token payloads (PR #1211) @@ -539,7 +541,8 @@ Version 5 is a complete code rewrite. - First major release -[Unreleased]: https://github.com/thephpleague/oauth2-server/compare/8.2.4...HEAD +[Unreleased]: https://github.com/thephpleague/oauth2-server/compare/8.3.0...HEAD +[8.3.0]: https://github.com/thephpleague/oauth2-server/compare/8.2.4...8.3.0 [8.2.4]: https://github.com/thephpleague/oauth2-server/compare/8.2.3...8.2.4 [8.2.3]: https://github.com/thephpleague/oauth2-server/compare/8.2.2...8.2.3 [8.2.2]: https://github.com/thephpleague/oauth2-server/compare/8.2.1...8.2.2