diff --git a/CHANGELOG.md b/CHANGELOG.md index b7738c2f7..048479c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ 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) +- 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) @@ -538,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 diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index f8d9168d2..8be7a7b0d 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -79,6 +79,11 @@ class AuthorizationServer implements EventDispatcherAwareInterface */ private $defaultScope = ''; + /** + * @var bool + */ + private $revokeRefreshTokens = true; + /** * New server instance. * @@ -136,6 +141,7 @@ public function enableGrantType(GrantTypeInterface $grantType, DateInterval $acc $grantType->setPrivateKey($this->privateKey); $grantType->useEventDispatcher($this->eventDispatcher); $grantType->setEncryptionKey($this->encryptionKey); + $grantType->revokeRefreshTokens($this->revokeRefreshTokens); $this->enabledGrantTypes[$grantType->getIdentifier()] = $grantType; $this->grantTypeAccessTokenTTL[$grantType->getIdentifier()] = $accessTokenTTL; @@ -233,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 revokeRefreshTokens(bool $revokeRefreshTokens): void + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } } diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index bab5d30dc..f69a4a5a2 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -93,6 +93,11 @@ abstract class AbstractGrant implements GrantTypeInterface */ protected $defaultScope; + /** + * @var bool + */ + protected $revokeRefreshTokens; + /** * @param ClientRepositoryInterface $clientRepository */ @@ -167,6 +172,14 @@ public function setDefaultScope($scope) $this->defaultScope = $scope; } + /** + * @param bool $revokeRefreshTokens + */ + public function revokeRefreshTokens(bool $revokeRefreshTokens) + { + $this->revokeRefreshTokens = $revokeRefreshTokens; + } + /** * Validate the client. * @@ -178,7 +191,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->dispatchEvent(new ClientAuthenticationFailed($clientId, $request)); @@ -239,7 +252,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/RefreshTokenGrant.php b/src/Grant/RefreshTokenGrant.php index 24717a17b..305c3ada8 100644 --- a/src/Grant/RefreshTokenGrant.php +++ b/src/Grant/RefreshTokenGrant.php @@ -65,7 +65,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); @@ -73,11 +75,12 @@ public function respondToAccessTokenRequest( $responseType->setAccessToken($accessToken); // Issue and persist new refresh token if given - $refreshToken = $this->issueRefreshToken($accessToken); - - if ($refreshToken !== null) { - $this->dispatchEvent(new RefreshTokenIssued($refreshToken, $request)); - $responseType->setRefreshToken($refreshToken); + if ($this->revokeRefreshTokens) { + $refreshToken = $this->issueRefreshToken($accessToken); + if ($refreshToken !== null) { + $this->dispatchEvent(new RefreshTokenIssued($refreshToken, $request)); + $responseType->setRefreshToken($refreshToken); + } } return $responseType; diff --git a/tests/Grant/RefreshTokenGrantTest.php b/tests/Grant/RefreshTokenGrantTest.php index 48e81f619..8f56fac4c 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 @@ -68,6 +69,7 @@ public function testRespondToRequest() $grant->setAccessTokenRepository($accessTokenRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -181,6 +183,7 @@ public function testRespondToReducedScopes() $grant->setScopeRepository($scopeRepositoryMock); $grant->setEncryptionKey($this->cryptStub->getKey()); $grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/../Stubs/private.key')); + $grant->revokeRefreshTokens(true); $oldRefreshToken = $this->cryptStub->doEncrypt( \json_encode( @@ -467,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->revokeRefreshTokens(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)); + } }