Skip to content

Commit

Permalink
Added support for revoking tokens
Browse files Browse the repository at this point in the history
Implementation follows the Draft RFC7009 OAuth 2.0 Token Revocation.

Essentially adds a unsetAccessToken method to all the storage objects, and exposes a revoke endpoint on controllers. The only interesting thing is the ResponseType\AccessToken::revokeToken() method which has to check all token types if the specified type doesn't exist.

Maintains BC with v1.x by commenting out the AccessToken interface methods, and annotated an @todo to implement these in 2.x. Throws a \RuntimeException if storage/response objects don't support the interface
  • Loading branch information
phindmarsh committed Aug 26, 2015
1 parent b60dda2 commit 7e07d7c
Show file tree
Hide file tree
Showing 15 changed files with 361 additions and 0 deletions.
54 changes: 54 additions & 0 deletions src/OAuth2/Controller/TokenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,58 @@ public function addGrantType(GrantTypeInterface $grantType, $identifier = null)

$this->grantTypes[$identifier] = $grantType;
}

public function handleRevokeRequest(RequestInterface $request, ResponseInterface $response)
{
if ($this->revokeToken($request, $response)) {
$response->setStatusCode(200);
$response->addParameters(array('revoked' => true));
}
}

/**
* Revoke a refresh or access token. Returns true on success and when tokens are invalid
*
* Note: invalid tokens do not cause an error response since the client
* cannot handle such an error in a reasonable way. Moreover, the
* purpose of the revocation request, invalidating the particular token,
* is already achieved.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool|null
*/
public function revokeToken(RequestInterface $request, ResponseInterface $response)
{
if (strtolower($request->server('REQUEST_METHOD')) != 'post') {
$response->setError(405, 'invalid_request', 'The request method must be POST when revoking an access token', '#section-3.2');
$response->addHttpHeaders(array('Allow' => 'POST'));

return null;
}

$token_type_hint = $request->request('token_type_hint');
if (!in_array($token_type_hint, array(null, 'access_token', 'refresh_token'), true)) {
$response->setError(400, 'invalid_request', 'Token type hint must be either \'access_token\' or \'refresh_token\'');

return null;
}

$token = $request->request('token');
if ($token === null) {
$response->setError(400, 'invalid_request', 'Missing token parameter to revoke');

return null;
}

// @todo remove this check for v2.0
if (!method_exists($this->accessToken, 'revokeToken')) {
$class = get_class($this->accessToken);
throw new \RuntimeException("AccessToken {$class} does not implement required revokeToken method");
}

$this->accessToken->revokeToken($token, $token_type_hint);

return true;
}
}
37 changes: 37 additions & 0 deletions src/OAuth2/ResponseType/AccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,41 @@ protected function generateRefreshToken()
{
return $this->generateAccessToken(); // let's reuse the same scheme for token generation
}

/**
* Handle the revoking of refresh tokens, and access tokens if supported / desirable
* RFC7009 specifies that "If the server is unable to locate the token using
* the given hint, it MUST extend its search across all of its supported token types"
*
* @param $token
* @param null $tokenTypeHint
* @return boolean
*/
public function revokeToken($token, $tokenTypeHint = null)
{
if ($tokenTypeHint == 'refresh_token') {
if ($this->refreshStorage && $revoked = $this->refreshStorage->unsetRefreshToken($token)) {
return true;
}
}

/** @TODO remove in v2 */
if (!method_exists($this->tokenStorage, 'unsetAccessToken')) {
throw new \RuntimeException(
sprintf('Token storage %s must implement unsetAccessToken method', get_class($this->tokenStorage)
));
}

$revoked = $this->tokenStorage->unsetAccessToken($token);

// if a typehint is supplied and fails, try other storages
// @see https://tools.ietf.org/html/rfc7009#section-2.1
if (!$revoked && $tokenTypeHint != 'refresh_token') {
if ($this->refreshStorage) {
$revoked = $this->refreshStorage->unsetRefreshToken($token);
}
}

return $revoked;
}
}
11 changes: 11 additions & 0 deletions src/OAuth2/ResponseType/AccessTokenInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@ interface AccessTokenInterface extends ResponseTypeInterface
* @ingroup oauth2_section_5
*/
public function createAccessToken($client_id, $user_id, $scope = null, $includeRefreshToken = true);

/**
* Handle the revoking of refresh tokens, and access tokens if supported / desirable
*
* @param $token
* @param $tokenTypeHint
* @return mixed
*
* @todo v2.0 include this method in interface. Omitted to maintain BC in v1.x
*/
//public function revokeToken($token, $tokenTypeHint);
}
18 changes: 18 additions & 0 deletions src/OAuth2/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@ public function grantAccessToken(RequestInterface $request, ResponseInterface $r
return $value;
}

/**
* Handle a revoke token request
* This would be called from the "/revoke" endpoint as defined in the draft Token Revocation spec
*
* @see https://tools.ietf.org/html/rfc7009#section-2
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return Response|ResponseInterface
*/
public function handleRevokeRequest(RequestInterface $request, ResponseInterface $response = null)
{
$this->response = is_null($response) ? new Response() : $response;
$this->getTokenController()->handleRevokeRequest($request, $this->response);

return $this->response;
}

/**
* Redirect the user appropriately after approval.
*
Expand Down
15 changes: 15 additions & 0 deletions src/OAuth2/Storage/AccessTokenInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,19 @@ public function getAccessToken($oauth_token);
* @ingroup oauth2_section_4
*/
public function setAccessToken($oauth_token, $client_id, $user_id, $expires, $scope = null);

/**
* Expire an access token.
*
* This is not explicitly required in the spec, but if defined in a draft RFC for token
* revoking (RFC 7009) https://tools.ietf.org/html/rfc7009
*
* @param $access_token
* Access token to be expired.
*
* @ingroup oauth2_section_6
*
* @todo v2.0 include this method in interface. Omitted to maintain BC in v1.x
*/
//public function unsetAccessToken($access_token);
}
5 changes: 5 additions & 0 deletions src/OAuth2/Storage/Cassandra.php
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,11 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s
);
}

public function unsetAccessToken($access_token)
{
return $this->expireValue($this->config['access_token_key'] . $access_token);
}

/* ScopeInterface */
public function scopeExists($scope)
{
Expand Down
10 changes: 10 additions & 0 deletions src/OAuth2/Storage/DynamoDB.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s

}

public function unsetAccessToken($access_token)
{
$result = $this->client->deleteItem(array(
'TableName' => $this->config['access_token_table'],
'Key' => $this->client->formatAttributes(array("access_token" => $access_token))
));

return true;
}

/* OAuth2\Storage\AuthorizationCodeInterface */
public function getAuthorizationCode($code)
{
Expand Down
8 changes: 8 additions & 0 deletions src/OAuth2/Storage/JwtAccessToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public function setAccessToken($oauth_token, $client_id, $user_id, $expires, $sc
}
}

public function unsetAccessToken($access_token)
{
if ($this->tokenStorage) {
return $this->tokenStorage->unsetAccessToken($access_token);
}
}


// converts a JWT access token into an OAuth2-friendly format
protected function convertJwtToOAuth2($tokenData)
{
Expand Down
5 changes: 5 additions & 0 deletions src/OAuth2/Storage/Memory.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,11 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s
return true;
}

public function unsetAccessToken($access_token)
{
unset($this->accessTokens[$access_token]);
}

public function scopeExists($scope)
{
$scope = explode(' ', trim($scope));
Expand Down
6 changes: 6 additions & 0 deletions src/OAuth2/Storage/Mongo.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s
return true;
}

public function unsetAccessToken($access_token)
{
$this->collection('access_token_table')->remove(array('access_token' => $access_token));
}


/* AuthorizationCodeInterface */
public function getAuthorizationCode($code)
{
Expand Down
7 changes: 7 additions & 0 deletions src/OAuth2/Storage/Pdo.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s
return $stmt->execute(compact('access_token', 'client_id', 'user_id', 'expires', 'scope'));
}

public function unsetAccessToken($access_token)
{
$stmt = $this->db->prepare(sprintf('DELETE FROM %s WHERE access_token = :access_token', $this->config['access_token_table']));

return $stmt->execute(compact('access_token'));
}

/* OAuth2\Storage\AuthorizationCodeInterface */
public function getAuthorizationCode($code)
{
Expand Down
5 changes: 5 additions & 0 deletions src/OAuth2/Storage/Redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ public function setAccessToken($access_token, $client_id, $user_id, $expires, $s
);
}

public function unsetAccessToken($access_token)
{
return $this->expireValue($this->config['access_token_key'] . $access_token);
}

/* ScopeInterface */
public function scopeExists($scope)
{
Expand Down
48 changes: 48 additions & 0 deletions test/OAuth2/Controller/TokenControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,54 @@ public function testCanReceiveAccessTokenUsingPasswordGrantTypeWithoutClientSecr
$this->assertNotNull($response->getParameter('token_type'));
}

public function testInvalidTokenTypeHintForRevoke()
{
$server = $this->getTestServer();

$request = TestRequest::createPost(array(
'token_type_hint' => 'foo',
'token' => 'sometoken'
));

$server->handleRevokeRequest($request, $response = new Response());

$this->assertTrue($response instanceof Response);
$this->assertEquals(400, $response->getStatusCode(), var_export($response, 1));
$this->assertEquals($response->getParameter('error'), 'invalid_request');
$this->assertEquals($response->getParameter('error_description'), 'Token type hint must be either \'access_token\' or \'refresh_token\'');
}

public function testMissingTokenForRevoke()
{
$server = $this->getTestServer();

$request = TestRequest::createPost(array(
'token_type_hint' => 'access_token'
));

$server->handleRevokeRequest($request, $response = new Response());
$this->assertTrue($response instanceof Response);
$this->assertEquals(400, $response->getStatusCode(), var_export($response, 1));
$this->assertEquals($response->getParameter('error'), 'invalid_request');
$this->assertEquals($response->getParameter('error_description'), 'Missing token parameter to revoke');
}

public function testInvalidRequestMethodForRevoke()
{
$server = $this->getTestServer();

$request = new TestRequest();
$request->setQuery(array(
'token_type_hint' => 'access_token'
));

$server->handleRevokeRequest($request, $response = new Response());
$this->assertTrue($response instanceof Response);
$this->assertEquals(405, $response->getStatusCode(), var_export($response, 1));
$this->assertEquals($response->getParameter('error'), 'invalid_request');
$this->assertEquals($response->getParameter('error_description'), 'The request method must be POST when revoking an access token');
}

public function testCreateController()
{
$storage = Bootstrap::getInstance()->getMemoryStorage();
Expand Down
Loading

0 comments on commit 7e07d7c

Please sign in to comment.