Skip to content

Commit

Permalink
Merge pull request #220 from ericclaeren/feature/accesstoken
Browse files Browse the repository at this point in the history
Support existing accessToken on OpenIdConnectAuthentication
  • Loading branch information
rojtjo authored Feb 9, 2023
2 parents bc5382e + c7a6e3f commit 273becf
Show file tree
Hide file tree
Showing 3 changed files with 355 additions and 20 deletions.
40 changes: 40 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ composer require 'php-twinfield/twinfield:^3.0'
You need to set up a `\PhpTwinfield\Secure\AuthenticatedConnection` class with your credentials. When using basic
username and password authentication, the `\PhpTwinfield\Secure\WebservicesAuthentication` class should be used, as follows:

#### Username and password
```php
$connection = new Secure\WebservicesAuthentication("username", "password", "organization");
```
Expand All @@ -30,6 +31,7 @@ $office = Office::fromCode("someOfficeCode");
$officeApi = new \PhpTwinfield\ApiConnectors\OfficeApiConnector($connection);
$officeApi->setOffice($office);
```
#### oAuth2

In order to use OAuth2 to authenticate with Twinfield, one should use the `\PhpTwinfield\Secure\Provider\OAuthProvider` to retrieve an `\League\OAuth2\Client\Token\AccessToken` object, and extract the refresh token from this object. Furthermore, it is required to set up a default `\PhpTwinfield\Office`, that will be used during requests to Twinfield. **Please note:** when a different office is specified when sending a request through one of the `ApiConnectors`, this Office will override the default.

Expand All @@ -47,6 +49,44 @@ $office = \PhpTwinfield\Office::fromCode("someOfficeCode");

$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office);
```

In the case you have an existing accessToken object you may pass that to the constructor or set it, to limit the amount of access token and validate requests, since the accessToken is valid for 1 hour.

```php
$provider = new OAuthProvider([
'clientId' => 'someClientId',
'clientSecret' => 'someClientSecret',
'redirectUri' => 'https://example.org/'
]);
$accessToken = $provider->getAccessToken("authorization_code", ["code" => ...]);
$refreshToken = $accessToken->getRefreshToken();
$office = \PhpTwinfield\Office::fromCode("someOfficeCode");

$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office, $accessToken);
```
or
```php
$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office);
$connection->setAccessToken($accessToken);
```

Setting an access token will force a new validation request on every login.

It's also possible to provide callables, that will be called when a new access token is validated.
This way you're able to store the new 'validated' access token object and your application can re-use the token within an hour.
This way you can optimize the number of requests.

**Be aware to store your access token in an appropriate and secure way. E.g. encrypting it.**

```php
$connection = new \PhpTwinfield\Secure\OpenIdConnectAuthentication($provider, $refreshToken, $office, $accessToken);
$connection->registerAfterValidateCallback(
function(\League\OAuth2\Client\Token\AccessTokenInterface $accessToken) {
// Store the access token.
}
);
```

For more information about retrieving the initial `AccessToken`, please refer to: https://github.com/thephpleague/oauth2-client#usage

### Getting data from the API
Expand Down
182 changes: 166 additions & 16 deletions src/Secure/OpenIdConnectAuthentication.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace PhpTwinfield\Secure;

use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessTokenInterface;
use PhpTwinfield\Office;
use PhpTwinfield\Secure\Provider\InvalidAccessTokenException;
use PhpTwinfield\Secure\Provider\OAuthException;
Expand All @@ -21,7 +22,7 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection
private $provider;

/**
* @var null|string
* @var AccessTokenInterface|null
*/
private $accessToken;

Expand All @@ -36,9 +37,19 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection
private $office;

/**
* @var string
* @var null|string
*/
private $cluster = null;

/**
* @var array
*/
private $cluster;
private $afterValidateCallbacks = [];

/**
* @var bool
*/
private $hasValidatedAccessToken = false;

/**
* The office code that is part of the Office object that is passed here will be
Expand All @@ -48,11 +59,17 @@ class OpenIdConnectAuthentication extends AuthenticatedConnection
* Please note that for most calls an office is mandatory. If you do not supply it
* you have to pass it with every request, or call setOffice.
*/
public function __construct(OAuthProvider $provider, string $refreshToken, ?Office $office)
public function __construct(
OAuthProvider $provider,
string $refreshToken,
?Office $office = null,
?AccessTokenInterface $accessToken = null
)
{
$this->provider = $provider;
$this->refreshToken = $refreshToken;
$this->office = $office;
$this->accessToken = $accessToken;
}

public function setOffice(?Office $office)
Expand All @@ -66,10 +83,21 @@ protected function getCluster(): ?string
return $this->cluster;
}

protected function getSoapHeaders()
protected function setCluster(?string $cluster): self
{
$this->cluster = $cluster;
return $this;
}

/**
* @throws InvalidAccessTokenException
*/
protected function getSoapHeaders(): \SoapHeader
{
$this->throwExceptionMissingAccessToken();

$headers = [
"AccessToken" => $this->accessToken,
"AccessToken" => $this->getAccessToken()->getToken(),
];

// Watch out. When you don't supply an Office and do an authenticated call you will get an
Expand All @@ -91,12 +119,26 @@ protected function getSoapHeaders()
*/
protected function login(): void
{
if ($this->accessToken === null) {
// Refresh the token when it's not set or is set, but expired or incomplete.
if (!$this->hasAccessToken() || $this->isExpiredAccessToken()) {
$this->refreshToken();
}

$validationResult = $this->validateToken();
$this->cluster = $validationResult["twf.clusterUrl"];
// There's no need to validate the access token if it's already validated and still valid.
if (!$this->hasValidatedAccessToken()) {
$validationResult = $this->validateToken();
$this->setCluster($validationResult["twf.clusterUrl"]);
}
}

public function hasValidatedAccessToken(): bool
{
return $this->hasValidatedAccessToken;
}

protected function setValidatedAccessToken(bool $validated = true): void
{
$this->hasValidatedAccessToken = $validated;
}

/**
Expand All @@ -109,34 +151,142 @@ protected function login(): void
*/
protected function validateToken(): array
{
$validationUrl = "https://login.twinfield.com/auth/authentication/connect/accesstokenvalidation?token=";
$validationResult = @file_get_contents($validationUrl . urlencode($this->accessToken));
$this->setValidatedAccessToken(false);
$this->throwExceptionMissingAccessToken();

if ($validationResult === false) {
throw new InvalidAccessTokenException("Access token is invalid.");
}
$accessToken = $this->getAccessToken();
$validationResult = $this->getValidationResult($accessToken);
$this->callAfterValidateCallbacks($accessToken);

$resultDecoded = \json_decode($validationResult, true);
if (\json_last_error() !== JSON_ERROR_NONE) {
throw new OAuthException("Error while decoding JSON: " . \json_last_error_msg());
}

return $resultDecoded;
}

/**
* @throws InvalidAccessTokenException
*/
protected function throwExceptionMissingAccessToken(): void
{
if (!$this->hasAccessToken()) {
throw new InvalidAccessTokenException("Missing access token.");
}
}

/**
* @throws InvalidAccessTokenException
*/
protected function getValidationResult(AccessTokenInterface $accessToken): string
{
$validationUrl = "https://login.twinfield.com/auth/authentication/connect/accesstokenvalidation?token=";
$validationResult = @file_get_contents($validationUrl . urlencode($accessToken->getToken()));

if ($validationResult === false) {
throw new InvalidAccessTokenException("Access token is invalid.");
}

return $validationResult;
}

/**
* @throws OAuthException
*/
protected function refreshToken(): void
{
// If you pass an empty refresh token, it will try to derive it from the accessToken object
// If that is set.
$refreshToken = !empty($this->refreshToken)
? $this->refreshToken
: ($this->hasAccessToken()
? $this->getAccessToken()->getRefreshToken() ?? null
: null
);

try {
$accessToken = $this->provider->getAccessToken(
"refresh_token",
["refresh_token" => $this->refreshToken]
["refresh_token" => $refreshToken]
);
} catch (IdentityProviderException $e) {
throw new OAuthException($e->getMessage(), 0, $e);
}

$this->accessToken = $accessToken->getToken();
$this->setAccessToken($accessToken);
}

/**
* Validate if there's an access token, and it's not expired.
* Will return true when there's no access token or expired is not set.
*
* @return bool
*/
protected function isExpiredAccessToken(): bool
{
$accessToken = $this->getAccessToken();
if ($accessToken instanceof AccessTokenInterface) {
try {
return $accessToken->hasExpired();
}
catch (\Exception $e) {}
}

return true;
}

/**
* @return AccessTokenInterface|null
*/
public function getAccessToken(): ?AccessTokenInterface
{
return $this->accessToken;
}

public function setAccessToken(?AccessTokenInterface $accessToken): self
{
$this->setValidatedAccessToken(false);
$this->accessToken = $accessToken;

return $this;
}

public function hasAccessToken(): bool
{
return $this->getAccessToken() instanceof AccessTokenInterface;
}

/**
* Register callbacks that will be invoked with the accessToken after a new access token is fetched.
* You may use this callback to store the acquired access token.
*
* Be aware, this access token grants access to the entire twinfield administration.
* Please store it in a safe place, preferable encrypted.
*
* @param callable $callable
* @return $this
*/
public function registerAfterValidateCallback(callable $callable): self
{
$this->afterValidateCallbacks[] = $callable;

return $this;
}

protected function getAfterValidateCallbacks(): array
{
return $this->afterValidateCallbacks;
}

protected function callAfterValidateCallbacks(AccessTokenInterface $accessToken): void
{
$callbacks = $this->getAfterValidateCallbacks();

if (!empty($callbacks)) {
foreach ($callbacks as $callback) {
$callback($accessToken);
}
}
}
}
Loading

0 comments on commit 273becf

Please sign in to comment.