diff --git a/README.md b/README.md index f0864b8..daa0e5c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ if (!isset($_GET['code'])) { } ``` +Keep in mind that this is only a simple example. In your application, you might +want to modify the scopes to your needs, use a database with persistent storage +for the token data, or revoke the tokens once they are no longer used. + ### Managing Scopes When creating your Discord authorization URL in Step 1, you can specify the state and scopes your application may authorize. @@ -148,6 +152,31 @@ if ($existingAccessToken->hasExpired()) { } ``` +### Revoking a Token + +No longer used tokens should be revoked so the authorization server can clean up +data associated with that token. This is useful when the end-user logs out or +uninstalls your application, and it can be achieved by invoking a simple request +to the authorization server using the refresh token: + +```php +$provider = new \Wohali\OAuth2\Client\Provider\Discord([ + 'clientId' => '{discord-client-id}', + 'clientSecret' => '{discord-client-secret}', +]); + +/** @var League\OAuth2\Client\Token\AccessTokenInterface $existingAccessToken */ +$existingAccessToken = getAccessTokenFromYourDataStore(); + +$provider->revokeToken([ + 'token' => $existingAccessToken->getRefreshToken(), + 'token_type_hint' => 'refresh_token', +]); +``` + +Keep in mind that due to the nature of this request, the authorization server +does not throw any error when a token is (already) invalid. + ### Client Credentials Grant Discord provides a client credentials flow for bot developers to get their own bearer tokens for testing purposes. This returns an access token for the *bot owner*: diff --git a/src/Provider/Discord.php b/src/Provider/Discord.php index c7f46d4..b348ffe 100644 --- a/src/Provider/Discord.php +++ b/src/Provider/Discord.php @@ -18,12 +18,15 @@ use League\OAuth2\Client\Provider\ResourceOwnerInterface; use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Tool\BearerAuthorizationTrait; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use UnexpectedValueException; use Wohali\OAuth2\Client\Provider\Exception\DiscordIdentityProviderException; class Discord extends AbstractProvider { use BearerAuthorizationTrait; + use TokenRevocationProviderTrait; /** * Default host @@ -60,6 +63,16 @@ public function getBaseAccessTokenUrl(array $params) return $this->apiDomain.'/oauth2/token'; } + /** + * Get revoke token URL to revoke an access/refresh token + * + * @return string + */ + public function getBaseRevokeTokenUrl() + { + return $this->apiDomain.'/oauth2/token/revoke'; + } + /** * Get provider URL to retrieve user details * @@ -130,4 +143,82 @@ protected function createResourceOwner(array $response, AccessToken $token) { return new DiscordResourceOwner($response); } + + /** + * Return the method to use when revoking the access/refresh token. + * + * @return string + */ + protected function getRevokeTokenMethod() + { + return self::METHOD_POST; + } + + /** + * Build request options used for revoking an access/refresh token. + * + * @param string $method + * @param array $params + * @return array + */ + protected function getRevokeTokenOptions(string $method, array $params) + { + $options = ['headers' => ['content-type' => 'application/x-www-form-urlencoded']]; + + if ($method === AbstractProvider::METHOD_POST) { + $options['body'] = $this->buildQueryString($params); + } + + return $options; + } + + /** + * Return a prepared request for revoking an access/refresh token. + * + * @param array $params + * @return RequestInterface + */ + protected function getRevokeTokenRequest(array $params) + { + $method = $this->getRevokeTokenMethod(); + $url = $this->getBaseRevokeTokenUrl(); + + $options = $this->getRevokeTokenOptions($method, $params); + + return $this->getRequest($method, $url, $options); + } + + /** + * Request a token revocation. + * + * Revoking an access/refresh token will invalidate it and clean up + * associated data with the underlying authorization grant. + * + * Revoking an access token MAY also invalidate the refresh token based on + * the same authorization grant. Based on the Discord documentation, it is + * the current behavior of the OAuth2 server. However, keep in mind that + * this behavior is only optional (according to the section 2.1, RFC7009). + * + * Revoking a refresh token will immediately invalidate all access tokens + * based on the same authorization grant. + * + * @param array $options Request parameters + * @return void + * + * @throws IdentityProviderException + * @throws UnexpectedValueException + */ + public function revokeToken(array $options) + { + $params = [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]; + + $params = $this->prepareRevokeTokenParameters($params, $options); + $request = $this->getRevokeTokenRequest($params); + + // The name is misleading, however, this also sends the request + $this->getParsedResponse($request); + } } diff --git a/src/Provider/TokenRevocationProviderTrait.php b/src/Provider/TokenRevocationProviderTrait.php new file mode 100644 index 0000000..8335a56 --- /dev/null +++ b/src/Provider/TokenRevocationProviderTrait.php @@ -0,0 +1,60 @@ + + * @license http://opensource.org/licenses/MIT MIT + * @link https://packagist.org/packages/wohali/oauth2-discord-new Packagist + * @link https://github.com/wohali/oauth2-discord-new GitHub + */ + +namespace Wohali\OAuth2\Client\Provider; + +use UnexpectedValueException; + +trait TokenRevocationProviderTrait +{ + /** + * Retrieve the OAuth Token Type Hint registry + * + * @return array + */ + protected function getRevokeTokenTypes() + { + return ['access_token', 'refresh_token']; + } + + /** + * Prepare request parameters for the token revocation request + * + * This makes sure that fields contain the correct value + * + * @param array $defaults + * @param array $options + * @return array + * + * @throws UnexpectedValueException + */ + protected function prepareRevokeTokenParameters(array $defaults, array $options) + { + $provided = array_merge($defaults, $options); + + // List of all known token types that can be revoked + $tokenTypes = $this->getRevokeTokenTypes(); + + if (isset($provided['token_type_hint']) && !in_array($provided['token_type_hint'], $tokenTypes)) { + throw new UnexpectedValueException( + sprintf( + 'Invalid token type hint "%s". The possible options are: %s', + $provided['token_type_hint'], + implode(', ', $tokenTypes) + ) + ); + } + + return $provided; + } +} diff --git a/test/src/Provider/DiscordTest.php b/test/src/Provider/DiscordTest.php index 1a857ff..99f6f8d 100644 --- a/test/src/Provider/DiscordTest.php +++ b/test/src/Provider/DiscordTest.php @@ -1,29 +1,32 @@ -provider = new \Wohali\OAuth2\Client\Provider\Discord([ + $this->provider = new Discord([ 'clientId' => 'mock_client_id', 'clientSecret' => 'mock_secret', 'redirectUri' => 'none' ]); } - public function tearDown(): void - { - m::close(); - parent::tearDown(); - } - public function testAuthorizationUrl() { $url = $this->provider->getAuthorizationUrl(); @@ -67,6 +70,14 @@ public function testGetBaseAccessTokenUrl() $this->assertEquals('/api/v9/oauth2/token', $uri['path']); } + public function testGetBaseRevokeTokenUrl() + { + $url = $this->provider->getBaseRevokeTokenUrl(); + $uri = parse_url($url); + + $this->assertEquals('/api/v9/oauth2/token/revoke', $uri['path']); + } + public function testGetAccessToken() { $response = m::mock('Psr\Http\Message\ResponseInterface'); @@ -86,6 +97,44 @@ public function testGetAccessToken() $this->assertNull($token->getResourceOwnerId()); } + public function testRevokeAccessToken() + { + $response = m::mock('Psr\Http\Message\ResponseInterface'); + $response->shouldReceive('getBody')->andReturn('{}'); + $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $response->shouldReceive('getStatusCode')->andReturn(200); + + $client = m::spy('GuzzleHttp\ClientInterface', [ + 'send' => $response, + ]); + + $this->provider->setHttpClient($client); + + $this->provider->revokeToken([ + 'client_id' => 'custom_client_id', + 'token' => 'mock_access_token', + ]); + + $client->shouldHaveReceived('send')->withArgs(function (RequestInterface $request) { + $contents = $request->getBody()->getContents(); + parse_str($contents, $data); + + return $request->getMethod() === 'POST' + && $data['token'] === 'mock_access_token' + && $data['client_id'] === 'custom_client_id'; + }); + } + + public function testExceptionThrownInvalidTokenTypeHint() + { + $this->expectException(UnexpectedValueException::class); + + $this->provider->revokeToken([ + 'token' => 'mock_random_token', + 'token_type_hint' => 'invalid', + ]); + } + public function testUserData() { $discriminator = rand(1000,9999); @@ -129,7 +178,7 @@ public function testUserData() public function testExceptionThrownErrorObjectReceived() { - $this->expectException(\League\OAuth2\Client\Provider\Exception\IdentityProviderException::class); + $this->expectException(IdentityProviderException::class); $status = rand(400,600); $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); $postResponse->shouldReceive('getBody')->andReturn('{"client_id": ["This field is required"]}');