diff --git a/src/AuthorizationServer.php b/src/AuthorizationServer.php index 8b0b2815e..5eb9a73fd 100644 --- a/src/AuthorizationServer.php +++ b/src/AuthorizationServer.php @@ -34,6 +34,11 @@ class AuthorizationServer implements EmitterAwareInterface */ protected $enabledGrantTypes = []; + /** + * @var RevokeTokenHandler + */ + protected $revokeTokenHandler = null; + /** * @var DateInterval[] */ @@ -206,6 +211,45 @@ public function respondToAccessTokenRequest(ServerRequestInterface $request, Res throw OAuthServerException::unsupportedGrantType(); } + /** + * Enable the revoke token handler on the server. + * + * @param RevokeTokenHandler $handler + */ + public function enableRevokeTokenHandler(RevokeTokenHandler $handler) + { + $handler->setAccessTokenRepository($this->accessTokenRepository); + $handler->setClientRepository($this->clientRepository); + $handler->setEncryptionKey($this->encryptionKey); + $handler->setEmitter($this->getEmitter()); + + $this->revokeTokenHandler = $handler; + } + + /** + * Return an revoke token response. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * + * @throws OAuthServerException + * + * @return ResponseInterface + */ + public function respondToRevokeTokenRequest(ServerRequestInterface $request, ResponseInterface $response) + { + if ($this->revokeTokenHandler !== null) { + $revokeResponse = $this->revokeTokenHandler->respondToRevokeTokenRequest($request, $this->getResponseType()); + + if ($revokeResponse instanceof ResponseTypeInterface) { + return $revokeResponse->generateHttpResponse($response); + } + } + + $errorMessage = 'Token revocation not supported.'; + throw new OAuthServerException($errorMessage, 3, 'invalid_request', 400); + } + /** * Get the token type that grants will return in the HTTP response. * diff --git a/src/Grant/AbstractGrant.php b/src/Grant/AbstractGrant.php index ead94db72..9c1c8b717 100644 --- a/src/Grant/AbstractGrant.php +++ b/src/Grant/AbstractGrant.php @@ -30,8 +30,8 @@ use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\Repositories\UserRepositoryInterface; -use League\OAuth2\Server\RequestEvent; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use League\OAuth2\Server\RequestValidatorTrait; use LogicException; use Psr\Http\Message\ServerRequestInterface; use TypeError; @@ -41,7 +41,7 @@ */ abstract class AbstractGrant implements GrantTypeInterface { - use EmitterAwareTrait, CryptTrait; + use EmitterAwareTrait, CryptTrait, RequestValidatorTrait; const SCOPE_DELIMITER_STRING = ' '; @@ -92,6 +92,14 @@ abstract class AbstractGrant implements GrantTypeInterface */ protected $defaultScope; + /** + * @return ClientRepositoryInterface + */ + public function getClientRepository() + { + return $this->clientRepository; + } + /** * @param ClientRepositoryInterface $clientRepository */ @@ -166,115 +174,6 @@ public function setDefaultScope($scope) $this->defaultScope = $scope; } - /** - * Validate the client. - * - * @param ServerRequestInterface $request - * - * @throws OAuthServerException - * - * @return ClientEntityInterface - */ - protected function validateClient(ServerRequestInterface $request) - { - list($clientId, $clientSecret) = $this->getClientCredentials($request); - - if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - - throw OAuthServerException::invalidClient($request); - } - - $client = $this->getClientEntityOrFail($clientId, $request); - - // If a redirect URI is provided ensure it matches what is pre-registered - $redirectUri = $this->getRequestParameter('redirect_uri', $request, null); - - if ($redirectUri !== null) { - $this->validateRedirectUri($redirectUri, $client, $request); - } - - return $client; - } - - /** - * Wrapper around ClientRepository::getClientEntity() that ensures we emit - * an event and throw an exception if the repo doesn't return a client - * entity. - * - * This is a bit of defensive coding because the interface contract - * doesn't actually enforce non-null returns/exception-on-no-client so - * getClientEntity might return null. By contrast, this method will - * always either return a ClientEntityInterface or throw. - * - * @param string $clientId - * @param ServerRequestInterface $request - * - * @return ClientEntityInterface - */ - protected function getClientEntityOrFail($clientId, ServerRequestInterface $request) - { - $client = $this->clientRepository->getClientEntity($clientId); - - if ($client instanceof ClientEntityInterface === false || empty($client->getRedirectUri())) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidClient($request); - } - - return $client; - } - - /** - * Gets the client credentials from the request from the request body or - * the Http Basic Authorization header - * - * @param ServerRequestInterface $request - * - * @return array - */ - protected function getClientCredentials(ServerRequestInterface $request) - { - list($basicAuthUser, $basicAuthPassword) = $this->getBasicAuthCredentials($request); - - $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); - - if (\is_null($clientId)) { - throw OAuthServerException::invalidRequest('client_id'); - } - - $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword); - - return [$clientId, $clientSecret]; - } - - /** - * Validate redirectUri from the request. - * If a redirect URI is provided ensure it matches what is pre-registered - * - * @param string $redirectUri - * @param ClientEntityInterface $client - * @param ServerRequestInterface $request - * - * @throws OAuthServerException - */ - protected function validateRedirectUri( - string $redirectUri, - ClientEntityInterface $client, - ServerRequestInterface $request - ) { - if (\is_string($client->getRedirectUri()) - && (\strcmp($client->getRedirectUri(), $redirectUri) !== 0) - ) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidClient($request); - } elseif (\is_array($client->getRedirectUri()) - && \in_array($redirectUri, $client->getRedirectUri(), true) === false - ) { - $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); - throw OAuthServerException::invalidClient($request); - } - } - /** * Validate scopes in the request. * @@ -320,97 +219,6 @@ private function convertScopesQueryStringToArray($scopes) }); } - /** - * Retrieve request parameter. - * - * @param string $parameter - * @param ServerRequestInterface $request - * @param mixed $default - * - * @return null|string - */ - protected function getRequestParameter($parameter, ServerRequestInterface $request, $default = null) - { - $requestParameters = (array) $request->getParsedBody(); - - return $requestParameters[$parameter] ?? $default; - } - - /** - * Retrieve HTTP Basic Auth credentials with the Authorization header - * of a request. First index of the returned array is the username, - * second is the password (so list() will work). If the header does - * not exist, or is otherwise an invalid HTTP Basic header, return - * [null, null]. - * - * @param ServerRequestInterface $request - * - * @return string[]|null[] - */ - protected function getBasicAuthCredentials(ServerRequestInterface $request) - { - if (!$request->hasHeader('Authorization')) { - return [null, null]; - } - - $header = $request->getHeader('Authorization')[0]; - if (\strpos($header, 'Basic ') !== 0) { - return [null, null]; - } - - if (!($decoded = \base64_decode(\substr($header, 6)))) { - return [null, null]; - } - - if (\strpos($decoded, ':') === false) { - return [null, null]; // HTTP Basic header without colon isn't valid - } - - return \explode(':', $decoded, 2); - } - - /** - * Retrieve query string parameter. - * - * @param string $parameter - * @param ServerRequestInterface $request - * @param mixed $default - * - * @return null|string - */ - protected function getQueryStringParameter($parameter, ServerRequestInterface $request, $default = null) - { - return isset($request->getQueryParams()[$parameter]) ? $request->getQueryParams()[$parameter] : $default; - } - - /** - * Retrieve cookie parameter. - * - * @param string $parameter - * @param ServerRequestInterface $request - * @param mixed $default - * - * @return null|string - */ - protected function getCookieParameter($parameter, ServerRequestInterface $request, $default = null) - { - return isset($request->getCookieParams()[$parameter]) ? $request->getCookieParams()[$parameter] : $default; - } - - /** - * Retrieve server parameter. - * - * @param string $parameter - * @param ServerRequestInterface $request - * @param mixed $default - * - * @return null|string - */ - protected function getServerParameter($parameter, ServerRequestInterface $request, $default = null) - { - return isset($request->getServerParams()[$parameter]) ? $request->getServerParams()[$parameter] : $default; - } - /** * Issue an access token. * diff --git a/src/RequestValidatorTrait.php b/src/RequestValidatorTrait.php new file mode 100644 index 000000000..ef07b56f2 --- /dev/null +++ b/src/RequestValidatorTrait.php @@ -0,0 +1,236 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ServerRequestInterface; + +trait RequestValidatorTrait +{ + /** + * Get the Emitter. + * + * @return \League\Event\EmitterInterface + */ + abstract public function getEmitter(); + + /** + * @return \League\OAuth2\Server\Repositories\ClientRepositoryInterface + */ + abstract public function getClientRepository(); + + /** + * Return the grant identifier that can be used in matching up requests. + * + * @return string + */ + abstract public function getIdentifier(); + + /** + * Validate the client. + * + * @param ServerRequestInterface $request + * + * @throws OAuthServerException + * + * @return ClientEntityInterface + */ + protected function validateClient(ServerRequestInterface $request) + { + list($clientId, $clientSecret) = $this->getClientCredentials($request); + + if ($this->clientRepository->validateClient($clientId, $clientSecret, $this->getIdentifier()) === false) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + + throw OAuthServerException::invalidClient($request); + } + + $client = $this->getClientEntityOrFail($clientId, $request); + + // If a redirect URI is provided ensure it matches what is pre-registered + $redirectUri = $this->getRequestParameter('redirect_uri', $request, null); + + if ($redirectUri !== null) { + $this->validateRedirectUri($redirectUri, $client, $request); + } + + return $client; + } + + /** + * Wrapper around ClientRepository::getClientEntity() that ensures we emit + * an event and throw an exception if the repo doesn't return a client + * entity. + * + * This is a bit of defensive coding because the interface contract + * doesn't actually enforce non-null returns/exception-on-no-client so + * getClientEntity might return null. By contrast, this method will + * always either return a ClientEntityInterface or throw. + * + * @param string $clientId + * @param ServerRequestInterface $request + * + * @return ClientEntityInterface + */ + protected function getClientEntityOrFail($clientId, ServerRequestInterface $request) + { + $client = $this->clientRepository->getClientEntity($clientId); + + if ($client instanceof ClientEntityInterface === false || empty($client->getRedirectUri())) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } + + return $client; + } + + /** + * Validate redirectUri from the request. + * If a redirect URI is provided ensure it matches what is pre-registered + * + * @param string $redirectUri + * @param ClientEntityInterface $client + * @param ServerRequestInterface $request + * + * @throws OAuthServerException + */ + protected function validateRedirectUri( + string $redirectUri, + ClientEntityInterface $client, + ServerRequestInterface $request + ) { + if (\is_string($client->getRedirectUri()) + && (\strcmp($client->getRedirectUri(), $redirectUri) !== 0) + ) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } elseif (\is_array($client->getRedirectUri()) + && \in_array($redirectUri, $client->getRedirectUri(), true) === false + ) { + $this->getEmitter()->emit(new RequestEvent(RequestEvent::CLIENT_AUTHENTICATION_FAILED, $request)); + throw OAuthServerException::invalidClient($request); + } + } + + /** + * Gets the client credentials from the request from the request body or + * the Http Basic Authorization header + * + * @param ServerRequestInterface $request + * + * @return array + */ + protected function getClientCredentials(ServerRequestInterface $request) + { + list($basicAuthUser, $basicAuthPassword) = $this->getBasicAuthCredentials($request); + + $clientId = $this->getRequestParameter('client_id', $request, $basicAuthUser); + + if (\is_null($clientId)) { + throw OAuthServerException::invalidRequest('client_id'); + } + + $clientSecret = $this->getRequestParameter('client_secret', $request, $basicAuthPassword); + + return [$clientId, $clientSecret]; + } + + /** + * Retrieve request parameter. + * + * @param string $parameter + * @param ServerRequestInterface $request + * @param mixed $default + * + * @return null|string + */ + protected function getRequestParameter($parameter, ServerRequestInterface $request, $default = null) + { + $requestParameters = (array) $request->getParsedBody(); + + return $requestParameters[$parameter] ?? $default; + } + + /** + * Retrieve HTTP Basic Auth credentials with the Authorization header + * of a request. First index of the returned array is the username, + * second is the password (so list() will work). If the header does + * not exist, or is otherwise an invalid HTTP Basic header, return + * [null, null]. + * + * @param ServerRequestInterface $request + * + * @return string[]|null[] + */ + protected function getBasicAuthCredentials(ServerRequestInterface $request) + { + if (!$request->hasHeader('Authorization')) { + return [null, null]; + } + + $header = $request->getHeader('Authorization')[0]; + if (\strpos($header, 'Basic ') !== 0) { + return [null, null]; + } + + if (!($decoded = \base64_decode(\substr($header, 6)))) { + return [null, null]; + } + + if (\strpos($decoded, ':') === false) { + return [null, null]; // HTTP Basic header without colon isn't valid + } + + return \explode(':', $decoded, 2); + } + + /** + * Retrieve query string parameter. + * + * @param string $parameter + * @param ServerRequestInterface $request + * @param mixed $default + * + * @return null|string + */ + protected function getQueryStringParameter($parameter, ServerRequestInterface $request, $default = null) + { + return isset($request->getQueryParams()[$parameter]) ? $request->getQueryParams()[$parameter] : $default; + } + + /** + * Retrieve cookie parameter. + * + * @param string $parameter + * @param ServerRequestInterface $request + * @param mixed $default + * + * @return null|string + */ + protected function getCookieParameter($parameter, ServerRequestInterface $request, $default = null) + { + return isset($request->getCookieParams()[$parameter]) ? $request->getCookieParams()[$parameter] : $default; + } + + /** + * Retrieve server parameter. + * + * @param string $parameter + * @param ServerRequestInterface $request + * @param mixed $default + * + * @return null|string + */ + protected function getServerParameter($parameter, ServerRequestInterface $request, $default = null) + { + return isset($request->getServerParams()[$parameter]) ? $request->getServerParams()[$parameter] : $default; + } +} diff --git a/src/RevokeTokenHandler.php b/src/RevokeTokenHandler.php new file mode 100644 index 000000000..615c7d77b --- /dev/null +++ b/src/RevokeTokenHandler.php @@ -0,0 +1,232 @@ + + * @copyright Copyright (c) Alex Bilbie + * @license http://mit-license.org/ + * + * @link https://github.com/thephpleague/oauth2-server + */ + +namespace League\OAuth2\Server; + +use Exception; +use Lcobucci\JWT\Parser; +use Lcobucci\JWT\Signer\Rsa\Sha256; +use Lcobucci\JWT\Token; +use League\Event\EmitterAwareInterface; +use League\Event\EmitterAwareTrait; +use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use League\OAuth2\Server\Repositories\ClientRepositoryInterface; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; +use Psr\Http\Message\ServerRequestInterface; + +class RevokeTokenHandler implements EmitterAwareInterface +{ + use EmitterAwareTrait, CryptTrait, RequestValidatorTrait; + + /** + * @var ClientRepositoryInterface + */ + protected $clientRepository; + + /** + * @var AccessTokenRepositoryInterface + */ + private $accessTokenRepository; + + /** + * @var RefreshTokenRepositoryInterface + */ + private $refreshTokenRepository; + + /** + * @var bool + */ + private $canRevokeAccessTokens; + + /** + * @var CryptKey + */ + protected $publicKey; + + /** + * New handler instance. + * + * @param RefreshTokenRepositoryInterface $refreshTokenRepository + * @param CryptKey|string $publicKey + * @param bool $canRevokeAccessTokens + */ + public function __construct( + RefreshTokenRepositoryInterface $refreshTokenRepository, + $publicKey, + $canRevokeAccessTokens = true + ) { + $this->setRefreshTokenRepository($refreshTokenRepository); + + if ($publicKey instanceof CryptKey === false) { + $publicKey = new CryptKey($publicKey); + } + $this->publicKey = $publicKey; + + $this->canRevokeAccessTokens = $canRevokeAccessTokens; + } + + /** + * @return ClientRepositoryInterface + */ + public function getClientRepository() + { + return $this->clientRepository; + } + + /** + * @param ClientRepositoryInterface $clientRepository + */ + public function setClientRepository(ClientRepositoryInterface $clientRepository) + { + $this->clientRepository = $clientRepository; + } + + /** + * @param AccessTokenRepositoryInterface $accessTokenRepository + */ + public function setAccessTokenRepository(AccessTokenRepositoryInterface $accessTokenRepository) + { + $this->accessTokenRepository = $accessTokenRepository; + } + + /** + * @param RefreshTokenRepositoryInterface $refreshTokenRepository + */ + public function setRefreshTokenRepository(RefreshTokenRepositoryInterface $refreshTokenRepository) + { + $this->refreshTokenRepository = $refreshTokenRepository; + } + + /** + * Set the public key + * + * @param CryptKey $key + */ + public function setPublicKey(CryptKey $key) + { + $this->publicKey = $key; + } + + /** + * Return the grant identifier that can be used in matching up requests. + * + * @return string + */ + public function getIdentifier() + { + return ''; + } + + /** + * Return a revoke token response. + * https://tools.ietf.org/html/rfc7009 + * + * @param ServerRequestInterface $request + * @param ResponseTypeInterface $responseType + * + * @throws OAuthServerException + * + * @return ResponseTypeInterface + */ + public function respondToRevokeTokenRequest(ServerRequestInterface $request, ResponseTypeInterface $responseType) + { + $token = $this->getRequestParameter('token', $request); + $hint = $this->getRequestParameter('token_type_hint', $request); + + // Validate request + $client = $this->validateClient($request); + $clientId = $client->getIdentifier(); + + if (\is_null($token)) { + return $responseType; + } + + // Attempt to revoke tokens + if ($hint === 'refresh_token') { + if (!$this->revokeRefreshToken($token, $clientId, $request)) { + $this->revokeAccessToken($token, $clientId, $request); + } + } else { + if (!$this->revokeAccessToken($token, $clientId, $request)) { + $this->revokeRefreshToken($token, $clientId, $request); + } + } + + return $responseType; + } + + /** + * @param string $tokenParam + * @param string $clientId + * + * @throws OAuthServerException + * + * @return bool true if token was a refresh token + */ + protected function revokeAccessToken($tokenParam, $clientId, ServerRequestInterface $request) + { + $token = null; + try { + $token = (new Parser())->parse($tokenParam); + + if ($token->verify(new Sha256(), $this->publicKey->getKeyPath()) === false) { + return false; + } + } catch (Exception $exception) { + // JWT couldn't be parsed as access token + return false; + } + + $clientId = $token->getClaim('aud'); + if ($clientId !== $clientId) { + throw OAuthServerException::invalidClient($request); + } + + if (!$this->canRevokeAccessTokens) { + $errorMessage = 'The authorization server does not support the revocation of the presented token type.'; + throw new OAuthServerException($errorMessage, 2, 'unsupported_token_type', 400); + } + $this->accessTokenRepository->revokeAccessToken($token->getClaim('jti')); + + return true; + } + + /** + * @param string $tokenParam + * @param string $clientId + * + * @throws OAuthServerException + * + * @return bool true if token was a refresh token + */ + protected function revokeRefreshToken($tokenParam, $clientId, ServerRequestInterface $request) + { + $refreshTokenData = null; + try { + $refreshToken = $this->decrypt($tokenParam); + $refreshTokenData = \json_decode($refreshToken, true); + } catch (Exception $e) { + // token couldn't be decrypted as refresh token + return false; + } + + if ($refreshTokenData['client_id'] !== $clientId) { + throw OAuthServerException::invalidClient($request); + } + + $this->refreshTokenRepository->revokeRefreshToken($refreshTokenData['refresh_token_id']); + if ($this->canRevokeAccessTokens) { + $this->accessTokenRepository->revokeAccessToken($refreshTokenData['access_token_id']); + } + + return true; + } +} diff --git a/tests/AuthorizationServerTest.php b/tests/AuthorizationServerTest.php index aa1707e61..e8648e5c5 100644 --- a/tests/AuthorizationServerTest.php +++ b/tests/AuthorizationServerTest.php @@ -18,6 +18,7 @@ use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; use League\OAuth2\Server\RequestTypes\AuthorizationRequest; use League\OAuth2\Server\ResponseTypes\BearerTokenResponse; +use League\OAuth2\Server\RevokeTokenHandler; use LeagueTests\Stubs\AccessTokenEntity; use LeagueTests\Stubs\AuthCodeEntity; use LeagueTests\Stubs\ClientEntity; @@ -349,4 +350,75 @@ public function testValidateAuthorizationRequestUnregistered() $server->validateAuthorizationRequest($request); } + + public function testRespondToRevokeRequestUnregistered() + { + $server = new AuthorizationServer( + $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(), + $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(), + $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(), + 'file://' . __DIR__ . '/Stubs/private.key', + \base64_encode(\random_bytes(36)), + new StubResponseType() + ); + + try { + $server->respondToRevokeTokenRequest(ServerRequestFactory::fromGlobals(), new Response); + } catch (OAuthServerException $e) { + $this->assertEquals('invalid_request', $e->getErrorType()); + $this->assertEquals(400, $e->getHttpStatusCode()); + } + } + + public function testRespondToRevokeRequest() + { + $client = new ClientEntity(); + $client->setRedirectUri('http://foo.bar'); + + $clientRepository = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepository->method('getClientEntity')->willReturn($client); + + $scope = new ScopeEntity(); + $scopeRepositoryMock = $this->getMockBuilder(ScopeRepositoryInterface::class)->getMock(); + $scopeRepositoryMock->method('getScopeEntityByIdentifier')->willReturn($scope); + $scopeRepositoryMock->method('finalizeScopes')->willReturnArgument(0); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->method('getNewToken')->willReturn(new AccessTokenEntity()); + + $server = new AuthorizationServer( + $clientRepository, + $accessTokenRepositoryMock, + $scopeRepositoryMock, + 'file://' . __DIR__ . '/Stubs/private.key', + \base64_encode(\random_bytes(36)), + new StubResponseType() + ); + + $server->setDefaultScope(self::DEFAULT_SCOPE); + $server->enableRevokeTokenHandler(new RevokeTokenHandler( + $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(), + 'file://' . __DIR__ . '/Stubs/public.key' + )); + + $request = new ServerRequest( + [], + [], + null, + 'POST', + 'php://input', + [], + [], + [], + [ + 'token' => 'abcdef', + 'token_type_hint' => 'access_token', + 'client_id' => 'foo', + 'client_secret' => 'bar', + ] + ); + + $response = $server->respondToRevokeTokenRequest($request, new Response); + $this->assertEquals(200, $response->getStatusCode()); + } } diff --git a/tests/RevokeTokenHandlerTest.php b/tests/RevokeTokenHandlerTest.php new file mode 100644 index 000000000..4ba379e23 --- /dev/null +++ b/tests/RevokeTokenHandlerTest.php @@ -0,0 +1,494 @@ +cryptStub = new CryptTraitStub(); + } + + public function testRespondToRequestValidAccessTokenWithHint() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $accessToken = new AccessTokenEntity(); + $accessToken->setIdentifier('test'); + $accessToken->setUserIdentifier(123); + $accessToken->setExpiryDateTime((new \DateTimeImmutable())->sub(new \DateInterval('PT1H'))); + $accessToken->setClient($client); + $accessToken->setPrivateKey(new CryptKey('file://' . __DIR__ . '/Stubs/private.key')); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => (string) $accessToken, + 'token_type_hint' => 'access_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestValidAccessTokenWithoutHint() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $accessToken = new AccessTokenEntity(); + $accessToken->setIdentifier('test'); + $accessToken->setUserIdentifier(123); + $accessToken->setExpiryDateTime((new \DateTimeImmutable())->sub(new \DateInterval('PT1H'))); + $accessToken->setClient($client); + $accessToken->setPrivateKey(new CryptKey('file://' . __DIR__ . '/Stubs/private.key')); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => (string) $accessToken, + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestValidAccessTokenButCannotRevokeAccessTokens() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->never())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey, false); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $accessToken = new AccessTokenEntity(); + $accessToken->setIdentifier('test'); + $accessToken->setUserIdentifier(123); + $accessToken->setExpiryDateTime((new \DateTimeImmutable())->sub(new \DateInterval('PT1H'))); + $accessToken->setClient($client); + $accessToken->setPrivateKey(new CryptKey('file://' . __DIR__ . '/Stubs/private.key')); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => (string) $accessToken, + 'token_type_hint' => 'access_token', + ] + ); + + $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); + $this->expectExceptionCode(2); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestValidRefreshTokenWithHint() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestValidRefreshTokenWithoutHint() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestValidRefreshTokenButCannotRevokeAccessTokens() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->never())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey, false); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestMissingToken() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->never())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestInvalidRefreshToken() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->never())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = 'foobar'; + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestClientMismatch() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->never())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->never())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'bar', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $this->expectException(\League\OAuth2\Server\Exception\OAuthServerException::class); + $this->expectExceptionCode(4); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestExpiredRefreshToken() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() - 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } + + public function testRespondToRequestRevokedRefreshToken() + { + $client = new ClientEntity(); + $client->setIdentifier('foo'); + $client->setRedirectUri('http://foo.bar'); + $clientRepositoryMock = $this->getMockBuilder(ClientRepositoryInterface::class)->getMock(); + $clientRepositoryMock->method('getClientEntity')->willReturn($client); + + $accessTokenRepositoryMock = $this->getMockBuilder(AccessTokenRepositoryInterface::class)->getMock(); + $accessTokenRepositoryMock->expects($this->once())->method('revokeAccessToken'); + $refreshTokenRepositoryMock = $this->getMockBuilder(RefreshTokenRepositoryInterface::class)->getMock(); + $refreshTokenRepositoryMock->expects($this->once())->method('revokeRefreshToken'); + + $publicKey = new CryptKey('file://' . __DIR__ . '/Stubs/public.key'); + $handler = new RevokeTokenHandler($refreshTokenRepositoryMock, $publicKey); + $handler->setClientRepository($clientRepositoryMock); + $handler->setAccessTokenRepository($accessTokenRepositoryMock); + $handler->setEncryptionKey($this->cryptStub->getKey()); + + $refreshToken = $this->cryptStub->doEncrypt( + \json_encode( + [ + 'client_id' => 'foo', + 'refresh_token_id' => 'zyxwvu', + 'access_token_id' => 'abcdef', + 'scopes' => ['foo'], + 'user_id' => 123, + 'expire_time' => \time() + 3600, + ] + ) + ); + + $serverRequest = new ServerRequest(); + $serverRequest = $serverRequest->withParsedBody( + [ + 'client_id' => 'foo', + 'client_secret' => 'bar', + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ] + ); + + $responseType = new StubResponseType(); + $handler->respondToRevokeTokenRequest($serverRequest, $responseType); + } +}