Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Token revocation #45

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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*:
Expand Down
91 changes: 91 additions & 0 deletions src/Provider/Discord.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
}
}
60 changes: 60 additions & 0 deletions src/Provider/TokenRevocationProviderTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
/**
* This file is part of the wohali/oauth2-discord-new library
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @copyright Copyright (c) Joan Touzet <code@atypical.net>
* @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;
}
}
69 changes: 59 additions & 10 deletions test/src/Provider/DiscordTest.php
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
<?php namespace Wohali\OAuth2\Client\Test\Provider;
<?php

namespace Wohali\OAuth2\Client\Test\Provider;

use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Tool\QueryBuilderTrait;
use Mockery as m;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use UnexpectedValueException;
use Wohali\OAuth2\Client\Provider\Discord;

class DiscordTest extends \PHPUnit\Framework\TestCase
class DiscordTest extends TestCase
{
use MockeryPHPUnitIntegration;
use QueryBuilderTrait;

protected $provider;

protected function setUp(): void
{
$this->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();
Expand Down Expand Up @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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"]}');
Expand Down