Skip to content

Commit

Permalink
Merge pull request #245 from helpscout/v2-automatically-refresh-expir…
Browse files Browse the repository at this point in the history
…ed-token

SDK v2: Automatically refresh expired token
  • Loading branch information
bkuhl authored Jul 2, 2020
2 parents f22c1a9 + 8b9e377 commit ba8af16
Show file tree
Hide file tree
Showing 9 changed files with 374 additions and 20 deletions.
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This is the official Help Scout PHP client. This client contains methods for eas
* [Installation](#installation)
* [Usage](#usage)
* [Examples](#examples)
* [Authentication](#authentication)
* [Customers](#customers)
* [Email](#email)
* [Address](#address)
Expand Down Expand Up @@ -54,7 +55,7 @@ You should always use Composer's autoloader in your application to autoload clas
require_once 'vendor/autoload.php';
```

### Creating the client
### Authentication

Use the factory to create a client. Once created, you can set the various credentials to make requests.

Expand Down Expand Up @@ -123,18 +124,30 @@ $client->setAccessToken('asdfasdf');
```
The access token will always be used if available, regardless of whether you have other credentials set or not.

### Refreshing Expired Tokens
### Automatically Refreshing Expired Tokens

While making API calls, if your token comes back expired you can refresh the token by:
When a request fails due to an authentication error, the SDK can automatically try to obtain a new refresh token and then retry the given request automatically. To enable this, you can provide a callback when creating the ApiClient which can be used to persist the token for other processes to use depending on your needs:

```
$client->getAuthenticator()->fetchAccessAndRefreshToken();
```php
$client = ApiClientFactory::createClient([], function (Authenticator $authenticator) {
// This $authenticator contains the refreshed token
echo 'New token: '.$authenticator->accessToken().PHP_EOL;
});
```

To persist the updated token you can use the authenticator that is returned:
The callback can also be any class instance that implements `HelpScout\Api\Http\Auth\HandlesTokenRefreshes`:

```
$client->getAuthenticator()->fetchAccessAndRefreshToken()->getTokens(); // array
```php
use HelpScout\Api\Http\Auth\HandlesTokenRefreshes;
use HelpScout\Api\Http\Authenticator;

$callback = new class implements HandlesTokenRefreshes {
public function whenTokenRefreshed(Authenticator $authenticator)
{
// @todo Persist the token
}
};
$client = ApiClientFactory::createClient([], $callback);
```

### Authorization Code Flow
Expand Down
22 changes: 22 additions & 0 deletions examples/refresh-token-and-retry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
require '_credentials.php';

use HelpScout\Api\ApiClientFactory;
use HelpScout\Api\Http\Authenticator;

/**
* The ApiClient can automatically refresh an expired token for you upon a failed request.
* When building the client a callback can be provided that will be executed immediately after
* the new token is issued.
*/
$client = ApiClientFactory::createClient([], function (Authenticator $authenticator) {
echo 'New token: '.$authenticator->accessToken().PHP_EOL;
});

$expiredRefreshToken = '';
$client = $client->useRefreshToken($appId, $appSecret, $expiredRefreshToken);

// Try to obtain a customer
$customer = $client->customers()->get(347089737);
var_dump($customer->getFirstEmail());
13 changes: 10 additions & 3 deletions src/ApiClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,21 @@

namespace HelpScout\Api;

use Closure;
use HelpScout\Api\Http\Auth\HandlesTokenRefreshes;
use HelpScout\Api\Http\RestClientBuilder;

class ApiClientFactory
{
public static function createClient(array $config = []): ApiClient
{
/**
* @param Closure|HandlesTokenRefreshes $tokenRefreshedCallback
*/
public static function createClient(
array $config = [],
$tokenRefreshedCallback = null
): ApiClient {
$restClientBuilder = new RestClientBuilder($config);

return new ApiClient($restClientBuilder->build());
return new ApiClient($restClientBuilder->build($tokenRefreshedCallback));
}
}
12 changes: 12 additions & 0 deletions src/Http/Auth/HandlesTokenRefreshes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace HelpScout\Api\Http\Auth;

use HelpScout\Api\Http\Authenticator;

interface HandlesTokenRefreshes
{
public function whenTokenRefreshed(Authenticator $authenticator);
}
42 changes: 38 additions & 4 deletions src/Http/Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

namespace HelpScout\Api\Http;

use Closure;
use GuzzleHttp\Client;
use HelpScout\Api\Http\Auth\Auth;
use HelpScout\Api\Http\Auth\ClientCredentials;
use HelpScout\Api\Http\Auth\CodeCredentials;
use HelpScout\Api\Http\Auth\HandlesTokenRefreshes;
use HelpScout\Api\Http\Auth\LegacyCredentials;
use HelpScout\Api\Http\Auth\NullCredentials;
use HelpScout\Api\Http\Auth\RefreshCredentials;
Expand Down Expand Up @@ -43,8 +46,10 @@ class Authenticator
private $ttl;

/**
* @param Auth $auth
* @var Closure|HandlesTokenRefreshes
*/
private $tokenRefreshedCallback;

public function __construct(Client $client, Auth $auth = null)
{
$this->client = $client;
Expand All @@ -61,16 +66,18 @@ public function getTokens(): array
];
}

public function tokenExpiresIn(): ?int
{
return $this->ttl;
}

public function setAccessToken(string $accessToken): Authenticator
{
$this->accessToken = $accessToken;

return $this;
}

/**
* @return string
*/
public function accessToken(): ?string
{
return $this->accessToken;
Expand Down Expand Up @@ -157,6 +164,25 @@ protected function fetchTokens(): void
}
}

/**
* @param Closure|HandlesTokenRefreshes $callback
*/
public function callbackWhenTokenRefreshed($callback)
{
$this->tokenRefreshedCallback = $callback;
}

public function shouldAutoRefreshAccessToken(): bool
{
$authTypesRequiringRefreshTokens = [
ClientCredentials::TYPE,
RefreshCredentials::TYPE,
CodeCredentials::TYPE,
];

return in_array($this->auth->getType(), $authTypesRequiringRefreshTokens) && $this->tokenRefreshedCallback !== null;
}

public function fetchAccessAndRefreshToken(): self
{
$tokens = $this->requestAuthTokens(
Expand All @@ -168,6 +194,14 @@ public function fetchAccessAndRefreshToken(): self
$this->ttl = $tokens['expires_in'];
$this->refreshToken = $tokens['refresh_token'] ?? null;

// If a new refresh token was obtained, execute any callback that was registered so the new token
// can be persisted and any other necessary actions can be performed within the app using the SDK
if ($this->tokenRefreshedCallback instanceof HandlesTokenRefreshes) {
$this->tokenRefreshedCallback->whenTokenRefreshed($this);
} elseif ($this->tokenRefreshedCallback instanceof Closure) {
call_user_func($this->tokenRefreshedCallback, $this);
}

return $this;
}

Expand Down
17 changes: 16 additions & 1 deletion src/Http/RestClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use GuzzleHttp\Psr7\Request;
use HelpScout\Api\ApiClient;
use HelpScout\Api\Entity\Extractable;
use HelpScout\Api\Exception\AuthenticationException;
use HelpScout\Api\Http\Hal\HalDeserializer;
use HelpScout\Api\Http\Hal\HalResource;
use HelpScout\Api\Http\Hal\HalResources;
Expand Down Expand Up @@ -170,6 +171,20 @@ private function send(Request $request)
'http_errors' => false,
];

return $this->client->send($request, $options);
try {
$response = $this->client->send($request, $options);
} catch (AuthenticationException $e) {
// If the request fails due to an authentication error, retry again after refreshing the token.
// This allows for token expirations to avoid impacting
$authenticator = $this->getAuthenticator();
if ($authenticator->shouldAutoRefreshAccessToken()) {
$authenticator->fetchAccessAndRefreshToken();
$response = $this->client->send($request, $options);
} else {
throw $e;
}
}

return $response;
}
}
20 changes: 16 additions & 4 deletions src/Http/RestClientBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use GuzzleHttp\Psr7\Response;
use HelpScout\Api\Http\Auth\Auth;
use HelpScout\Api\Http\Auth\ClientCredentials;
use HelpScout\Api\Http\Auth\HandlesTokenRefreshes;
use HelpScout\Api\Http\Auth\LegacyCredentials;
use HelpScout\Api\Http\Auth\NullCredentials;
use HelpScout\Api\Http\Auth\RefreshCredentials;
Expand All @@ -32,11 +33,16 @@ public function __construct(array $config = [])
$this->config = $config;
}

public function build(): RestClient
/**
* @param \Closure|HandlesTokenRefreshes $tokenRefreshedCallback
*/
public function build($tokenRefreshedCallback = null): RestClient
{
$authenticator = $this->getAuthenticator($tokenRefreshedCallback);

return new RestClient(
$this->getGuzzleClient(),
$this->getAuthenticator()
$authenticator
);
}

Expand All @@ -47,14 +53,20 @@ protected function getGuzzleClient(): Client
return new Client($options);
}

protected function getAuthenticator(): Authenticator
protected function getAuthenticator($tokenRefreshedCallback = null): Authenticator
{
$authConfig = $this->config['auth'] ?? [];

return new Authenticator(
$authenticator = new Authenticator(
new Client(),
$this->getAuthClass($authConfig)
);

if ($tokenRefreshedCallback !== null) {
$authenticator->callbackWhenTokenRefreshed($tokenRefreshedCallback);
}

return $authenticator;
}

protected function getAuthClass(array $authConfig = []): Auth
Expand Down
Loading

0 comments on commit ba8af16

Please sign in to comment.