Skip to content

Commit

Permalink
:octocat: RFC-7009 Token Revocation
Browse files Browse the repository at this point in the history
  • Loading branch information
codemasher committed May 19, 2024
1 parent 852edf6 commit 9aeed31
Show file tree
Hide file tree
Showing 18 changed files with 182 additions and 224 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ A transparent, framework-agnostic, easily extensible PHP [PSR-18](https://www.ph
- [Client Credentials Grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)
- [Token refresh](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
- [CSRF Token](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12) ("state" parameter)
- [RFC-7009: Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009)
- [RFC-7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key for Code Exchange)
- [RFC-9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126) (Pushed Authorization Requests)
- Proprietary, OAuth-like authorization flows (e.g. [Last.fm](https://www.last.fm/api/authentication))
Expand Down
1 change: 1 addition & 0 deletions docs/Basics/Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ fully [PSR-7](https://www.php-fig.org/psr/psr-7/)/[PSR-17](https://www.php-fig.o
- [Client Credentials Grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)
- [Token refresh](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
- [CSRF Token](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12) ("state" parameter)
- [RFC-7009: Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009)
- [RFC-7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key for Code Exchange)
- [RFC-9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126) (Pushed Authorization Requests)
- Proprietary, OAuth-like authorization flows (e.g. [Last.fm](https://www.last.fm/api/authentication))
Expand Down
52 changes: 23 additions & 29 deletions docs/Development/Additional-functionality.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,16 @@ class MyOAuth2Provider extends OAuth2Provider implements PAR{

## `TokenInvalidate`

This is interface is *not* implemented in the abstract providers, as it may differ drastically between services or is not supported at all.
The `TokenInvalidate` adds support for *"Token Revocation""* as described in [RFC-7009](https://datatracker.ietf.org/doc/html/rfc7009).
The method `TokenInvalidate::invalidateAccessToken()` takes an `AccessToken` as optional parameter, in which case this token should be invalidated,
otherwise the token for the current user should be fetched from the storage and be used in the invalidation request.
An optional ["token type hint"](https://datatracker.ietf.org/doc/html/rfc7009#section-2.1) can be given with the `$type` parameter (defaults to `access_token`).

The more common implementation looks as follows: the access token along with client-id is sent with a `POST` request as url-encoded
form-data in the body, and the server responds with either a HTTP 200 and (often) an empty body or a HTTP 204.
On a successful response, the token should be deleted from the storage.
The more common implementation looks as follows: the access token along with type hint (and sometimes other parameters) is sent
with a `POST` request as url-encoded form-data in the body, and the server responds with either an HTTP 200 and (often) an empty
body or an HTTP 204. On a successful response, the token should be deleted from the storage.

The implementation in `OAuth2Provider` is divided in parts that can be overridden separately:

```php
class MyOAuth2Provider extends OAuth2Provider implements TokenInvalidate{
Expand All @@ -281,37 +284,28 @@ class MyOAuth2Provider extends OAuth2Provider implements TokenInvalidate{
* ...
*/

public function invalidateAccessToken(AccessToken|null $token = null):bool{
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));

// the body may vary between services
$bodyParams = [
'client_id' => $this->options->key,
'token' => $tokenToInvalidate->accessToken,
];
protected function sendTokenInvalidateRequest(string $url, array $body){

// prepare the request
$request = $this->requestFactory
->createRequest('POST', $this->revokeURL)
->createRequest('POST', $url)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
;

// encode the body according to the content-type given in the request header
$request = $this->setRequestBody($bodyParams, $request);
// an additional basic auth header is set
$request = $this->addBasicAuthHeader($request);
$request = $this->setRequestBody($body, $request);

// bypass the host check and request authorization
$response = $this->http->sendRequest($request);

if($response->getStatusCode() === 200){
// delete the token on success (only if it wasn't given via param)
if($token === null){
$this->storage->clearAccessToken($this->name);
}

return true;
}
return $this->http->sendRequest($request);
}

return false;
protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{
return [
// here, client_id and client_secret are set additionally
'client_id' => $this->options->key,
'client_secret' => $this->options->secret,
'token' => $token->accessToken,
'token_type_hint' => $type,
];
}

}
Expand All @@ -328,7 +322,7 @@ class MyOAuth2Provider extends OAuth2Provider implements TokenInvalidate{
* ...
*/

public function invalidateAccessToken(AccessToken|null $token = null):bool{
public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{

// a token was given
if($token !== null){
Expand Down
6 changes: 3 additions & 3 deletions docs/index.rst
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
.. php-qrcode documentation master file, created by sphinx-quickstart on Sun Jul 9 21:45:56 2023.
markdown-rst converter: https://pandoc.org/try/
================
PHP-OAuth Manual
================
===========================
chillerlan PHP-OAuth Manual
===========================

User manual for `chillerlan/php-oauth <https://github.com/chillerlan/php-oauth/>`__ [|version|]. Updated on |today|.

Expand Down
78 changes: 77 additions & 1 deletion src/Core/OAuth2Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
use chillerlan\OAuth\Providers\ProviderException;
use Psr\Http\Message\{RequestInterface, ResponseInterface, UriInterface};
use Throwable;
use function array_merge, date, explode, hash, hash_equals, implode, in_array, is_array, random_int, sodium_bin2base64, sprintf;
use function array_merge, date, explode, hash, hash_equals, implode, in_array, is_array, random_int,
sodium_bin2base64, sprintf, str_contains, strtolower, trim;
use const PHP_QUERY_RFC1738, PHP_VERSION_ID, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING;

/**
Expand Down Expand Up @@ -407,6 +408,81 @@ protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):
}


/*
* TokenInvalidate
*/

/**
* @implements \chillerlan\OAuth\Core\TokenInvalidate::invalidateAccessToken()
* @throws \chillerlan\OAuth\Providers\ProviderException
*/
public function invalidateAccessToken(AccessToken $token = null, string|null $type = null):bool{
$type = strtolower(trim($type ?? 'access_token'));

// @link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
if(!in_array($type, ['access_token', 'refresh_token'])){
throw new ProviderException(sprintf('invalid token type "%s"', $type));
}

$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));
$body = $this->getInvalidateAccessTokenBodyParams($tokenToInvalidate, $type);
$response = $this->sendTokenInvalidateRequest($this->revokeURL, $body);

// some endpoints may return 204, others 200 with empty body
if(in_array($response->getStatusCode(), [200, 204], true)){

// if the token was given via parameter it cannot be deleted from storage
if($token === null){
$this->storage->clearAccessToken($this->name);
}

return true;
}

// ok, let's see if we got a response body
// @link https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
if(str_contains($response->getHeaderLine('content-type'), 'json')){
$json = MessageUtil::decodeJSON($response);

if(isset($json['error'])){
throw new ProviderException($json['error']);
}
}

return false;
}

/**
* Prepares and sends a request to the token invalidation endpoint
*
* @see \chillerlan\OAuth\Core\OAuth2Provider::invalidateAccessToken()
*/
protected function sendTokenInvalidateRequest(string $url, array $body):ResponseInterface{

$request = $this->requestFactory
->createRequest('POST', $url)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
;

// some enpoints may require a basic auth header here
$request = $this->setRequestBody($body, $request);

return $this->http->sendRequest($request);
}

/**
* Prepares the body for a token revocation request
*
* @see \chillerlan\OAuth\Core\OAuth2Provider::invalidateAccessToken()
*/
protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{
return [
'token' => $token->accessToken,
'token_type_hint' => $type,
];
}


/*
* CSRFToken
*/
Expand Down
6 changes: 4 additions & 2 deletions src/Core/TokenInvalidate.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
namespace chillerlan\OAuth\Core;

/**
* Indicates whether the provider is capable of invalidating access tokens
* Indicates whether the provider is capable of invalidating access tokens (RFC-7009 or proprietary)
*
* @link https://datatracker.ietf.org/doc/html/rfc7009
*/
interface TokenInvalidate{

Expand All @@ -29,6 +31,6 @@ interface TokenInvalidate{
*
* @throws \chillerlan\OAuth\Providers\ProviderException
*/
public function invalidateAccessToken(AccessToken|null $token = null):bool;
public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool;

}
2 changes: 1 addition & 1 deletion src/Providers/BigCartel.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function me():AuthenticatedUser{
/**
* @inheritDoc
*/
public function invalidateAccessToken(AccessToken|null $token = null):bool{
public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));

$request = $this->requestFactory
Expand Down
2 changes: 1 addition & 1 deletion src/Providers/DeviantArt.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public function me():AuthenticatedUser{
/**
* @inheritDoc
*/
public function invalidateAccessToken(AccessToken|null $token = null):bool{
public function invalidateAccessToken(AccessToken|null $token = null, string|null $type = null):bool{

if($token !== null){
// to revoke a token different from the one of the currently authenticated user,
Expand Down
45 changes: 13 additions & 32 deletions src/Providers/Discord.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ class Discord extends OAuth2Provider implements ClientCredentials, CSRFToken, To
protected string|null $apiDocs = 'https://discord.com/developers/';
protected string|null $applicationURL = 'https://discordapp.com/developers/applications/';

/**
* @inheritDoc
* @link https://github.com/discord/discord-api-docs/issues/2259#issuecomment-927180184
*/
protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{
return [
'client_id' => $this->options->key,
'client_secret' => $this->options->secret,
'token' => $token->accessToken,
'token_type_hint' => $type,
];
}

/**
* @inheritDoc
* @codeCoverageIgnore
Expand All @@ -84,36 +97,4 @@ public function me():AuthenticatedUser{
return new AuthenticatedUser($userdata);
}

/**
* @inheritDoc
*/
public function invalidateAccessToken(AccessToken $token = null):bool{
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));

$bodyParams = [
'client_id' => $this->options->key,
'client_secret' => $this->options->secret,
'token' => $tokenToInvalidate->accessToken,
];

$request = $this->requestFactory
->createRequest('POST', $this->revokeURL)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
;

$request = $this->setRequestBody($bodyParams, $request);
$response = $this->http->sendRequest($request);

if($response->getStatusCode() === 200){

if($token === null){
$this->storage->clearAccessToken($this->name);
}

return true;
}

return false;
}

}
44 changes: 12 additions & 32 deletions src/Providers/MusicBrainz.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ protected function getRefreshAccessTokenRequestBodyParams(string $refreshToken):
];
}

/**
* @inheritDoc
*/
protected function getInvalidateAccessTokenBodyParams(AccessToken $token, string $type):array{
return [
'client_id' => $this->options->key,
'client_secret' => $this->options->secret,
'token' => $token->accessToken,
'token_type_hint' => $type,
];
}

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -108,36 +120,4 @@ public function me():AuthenticatedUser{
return new AuthenticatedUser($userdata);
}

/**
* @inheritDoc
*/
public function invalidateAccessToken(AccessToken|null $token = null):bool{
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));

$request = $this->requestFactory
->createRequest('POST', $this->revokeURL)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
;

$bodyParams = [
'client_id' => $this->options->key,
'client_secret' => $this->options->secret,
'token' => $tokenToInvalidate->accessToken,
];

$request = $this->setRequestBody($bodyParams, $request);
$response = $this->http->sendRequest($request);

if($response->getStatusCode() === 200){

if($token === null){
$this->storage->clearAccessToken($this->name);
}

return true;
}

return false;
}

}
33 changes: 1 addition & 32 deletions src/Providers/NPROne.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

namespace chillerlan\OAuth\Providers;

use chillerlan\OAuth\Core\{AccessToken, AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenRefresh, UserInfo};
use chillerlan\OAuth\Core\{AuthenticatedUser, CSRFToken, OAuth2Provider, TokenInvalidate, TokenRefresh, UserInfo};
use function in_array, sprintf, strtolower;

/**
Expand Down Expand Up @@ -76,35 +76,4 @@ public function me():AuthenticatedUser{
return new AuthenticatedUser($userdata);
}

/**
* @inheritDoc
*/
public function invalidateAccessToken(AccessToken|null $token = null):bool{
$tokenToInvalidate = ($token ?? $this->storage->getAccessToken($this->name));

$bodyParams = [
'token' => $tokenToInvalidate->accessToken,
'token_type_hint' => 'access_token',
];

$request = $this->requestFactory
->createRequest('POST', $this->revokeURL)
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
;

$request = $this->setRequestBody($bodyParams, $request);
$response = $this->http->sendRequest($request);

if($response->getStatusCode() === 200){

if($token === null){
$this->storage->clearAccessToken($this->name);
}

return true;
}

return false;
}

}
Loading

0 comments on commit 9aeed31

Please sign in to comment.