Skip to content

Commit

Permalink
Merge pull request #28 from DivineOmega/feature/oauth2
Browse files Browse the repository at this point in the history
OAuth 2.0 support
  • Loading branch information
Jordan Hall authored Mar 30, 2020
2 parents 02ba19d + 0d77c51 commit e486e18
Show file tree
Hide file tree
Showing 10 changed files with 254 additions and 109 deletions.
110 changes: 87 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
Xero Laravel allows developers to access the Xero accounting system using
an Eloquent-like syntax.

Please note that this version of Xero Laravel supports the Xero OAuth 2.0
implementation. Older Xero apps using OAuth 1.x are no longer supported.

<p align="center">
<img src="assets/images/xero-laravel-usage.png" />
</p>
Expand Down Expand Up @@ -48,41 +51,102 @@ file should be sufficient. All you will need to do is add the following
variables to your `.env` file.

```
XERO_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XERO_TENANT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XERO_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XERO_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
XERO_REDIRECT_URI=https://example.com/xero-callback
```

## Migration from 1.x/OAuth 1a

There is now only one flow for all applications, which is most similar to the legacy Public application.
All applications now require the OAuth 2 authorisation flow and specific organisations to be authorised
at runtime, rather than creating certificates during app creation.

Following [this example](https://github.com/calcinai/xero-php#authorization-code-flow) you can generate the
required token and tenant id.
### OAuth 2.0 flow

For more information on scopes try the [xero documentation](https://developer.xero.com/documentation/oauth2/scopes).
In order for users to make use of your Xero app, they must first give your app permission to access their Xero account.
To do this, your web application must do the following.

## Usage
1. Redirect the user to the Xero authorization URL.
2. Capture the response from Xero, and obtain an access token.
3. Retrieve the list of tenants (typically Xero organisations), and let the user select one.
4. Store the access token and selected tenant ID against the user's account for future use.
5. Before using the access token, check if it has expired and refresh it if necessary.

To use Xero Laravel, you first need to get retrieve an instance of your Xero
app. This can be done as shown below.
The controller below shows these steps in action.

```php
$xero = (new Xero())->app(); # To use the 'default' app in the config file
$xero = (new Xero())->app('foobar'); # To use a custom app called 'foobar' in the config file
<?php
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use LangleyFoxall\XeroLaravel\OAuth2;
use League\OAuth2\Client\Token\AccessToken;

class XeroController extends Controller
{
private function getOAuth2()
{
// This will use the 'default' app configuration found in your 'config/xero-laravel-lf.php` file.
// If you wish to use an alternative app configuration you can specify its key (e.g. `new OAuth2('other_app')`).
return new OAuth2();
}

public function redirectUserToXero()
{
// Step 1 - Redirect the user to the Xero authorization URL.
return $this->getOAuth2()->getAuthorizationRedirect();
}

public function handleCallbackFromXero(Request $request)
{
// Step 2 - Capture the response from Xero, and obtain an access token.
$accessToken = $this->getOAuth2()->getAccessTokenFromXeroRequest($request);

// Step 3 - Retrieve the list of tenants (typically Xero organisations), and let the user select one.
$tenants = $this->getOAuth2()->getTenants($accessToken);
$selectedTenant = $tenants[0]; // For example purposes, we're pretending the user selected the first tenant.

// Step 4 - Store the access token and selected tenant ID against the user's account for future use.
// You can store these anyway you wish. For this example, we're storing them in the database using Eloquent.
$user = auth()->user();
$user->xero_access_token = json_encode($accessToken);
$user->tenant_id = $selectedTenant->tenantId;
$user->save();
}

public function refreshAccessTokenIfNecessary()
{
// Step 5 - Before using the access token, check if it has expired and refresh it if necessary.
$user = auth()->user();
$accessToken = new AccessToken(json_decode($user->xero_access_token));

if ($accessToken->hasExpired()) {
$accessToken = $this->getOAuth2()->refreshAccessToken($accessToken);

$user->xero_access_token = $accessToken;
$user->save();
}
}
}
```

Alternately you can use the Xero facade
*Note this is only for the default config*
By default, only a limited number of scopes are defined in the configuration file (space separated). You will probably
want to add to the scopes depending on your application's intended purpose. For example adding the
`accounting.transactions` scope allows you to manage invoices, and adding the `accounting.contacts.read` allows you to
read contact information.

Xero's documentation provides a full [list of available scopes](https://developer.xero.com/documentation/oauth2/scopes).


## Usage

To use Xero Laravel, you first need to get retrieve your user's stored access token and tenant id. You can use these
to create a new `XeroApp` object which represents your Xero application.

```php
use LangleyFoxall\XeroLaravel\Facades\Xero;
use LangleyFoxall\XeroLaravel\XeroApp;
use League\OAuth2\Client\Token\AccessToken;

# Retrieve all contacts via facade
$contacts = Xero::contacts()->get();
$user = auth()->user();

# Retrieve an individual contact by its GUID
$contact = Xero::contacts()->find('34xxxx6e-7xx5-2xx4-bxx5-6123xxxxea49');
$xero = new XeroApp(
new AccessToken(json_decode($user->xero_oauth_2_access_token)),
$user->xero_tenant_id
);
```

You can then immediately access Xero data using Eloquent-like syntax. The
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"accounting"
],
"require": {
"laravel/framework": "^5.1||^6.0",
"php":">=7.1",
"laravel/framework": "^5.1||^6.0||^7.0",
"calcinai/xero-php": "^2.0"
},
"autoload": {
Expand Down
8 changes: 8 additions & 0 deletions src/Exceptions/InvalidOAuth2StateException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace LangleyFoxall\XeroLaravel\Exceptions;

class InvalidOAuth2StateException extends \Exception
{
//
}
8 changes: 8 additions & 0 deletions src/Exceptions/InvalidXeroRequestException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace LangleyFoxall\XeroLaravel\Exceptions;

class InvalidXeroRequestException extends \Exception
{
//
}
18 changes: 0 additions & 18 deletions src/Facades/Xero.php

This file was deleted.

139 changes: 139 additions & 0 deletions src/OAuth2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

namespace LangleyFoxall\XeroLaravel;

use Calcinai\OAuth2\Client\Provider\Xero as Provider;
use Calcinai\OAuth2\Client\XeroTenant;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use InvalidArgumentException;
use LangleyFoxall\XeroLaravel\Exceptions\InvalidOAuth2StateException;
use LangleyFoxall\XeroLaravel\Exceptions\InvalidXeroRequestException;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessTokenInterface;

class OAuth2
{
const KEYS = [
'SESSION_STATE' => 'xero-oauth-2-session-state',
];

/** @var string $clientId */
protected $clientId;

/** @var string $clientSecret */
protected $clientSecret;

/** @var string $redirectUri */
protected $redirectUri;

/** @var string $scope */
protected $scope;

/**
* OAuth2 constructor.
*
* @param string $key
*/
public function __construct(string $key = 'default')
{
$config = config(Constants::CONFIG_KEY);

if (!isset($config['apps'][$key])) {
throw new InvalidArgumentException('Invalid app key specified. Please check your `xero-laravel-lf` configuration file.');
}

$app = $config['apps'][$key];

$this->clientId = $app['client_id'];
$this->clientSecret = $app['client_secret'];
$this->redirectUri = $app['redirect_uri'];
$this->scope = $app['scope'];
}

/**
* Get the OAuth2 provider.
*
* @return Provider
*/
private function getProvider()
{
return new Provider([
'clientId' => $this->clientId,
'clientSecret' => $this->clientSecret,
'redirectUri' => $this->redirectUri,
]);
}

/**
* Get a redirect to the Xero authorization URL.
*
* @return RedirectResponse|Redirector
*/
public function getAuthorizationRedirect()
{
$provider = $this->getProvider();

$authUri = $provider->getAuthorizationUrl(['scope' => $this->scope]);

session()->put(self::KEYS['SESSION_STATE'], $provider->getState());

return redirect($authUri);
}

/**
* Handle the incoming request from Xero, request an access token and return it.
*
* @param Request $request
* @return AccessTokenInterface
* @throws IdentityProviderException
* @throws InvalidOAuth2StateException
* @throws InvalidXeroRequestException
*/
public function getAccessTokenFromXeroRequest(Request $request)
{
$code = $request->get('code');
$state = $request->get('state');

if (!$code) {
throw new InvalidXeroRequestException('No `code` present in request from Xero.');
}

if (!$state) {
throw new InvalidXeroRequestException('No `state` present in request from Xero.');
}

if ($state !== session(self::KEYS['SESSION_STATE'])) {
throw new InvalidOAuth2StateException('Invalid `state`. Request may have been tampered with.');
}

return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]);
}

/**
* Get all the tenants (typically Xero organisations) that the access token is able to access.
*
* @param AccessTokenInterface $accessToken
* @return XeroTenant[]
* @throws IdentityProviderException
*/
public function getTenants(AccessTokenInterface $accessToken)
{
return $this->getProvider()->getTenants($accessToken);
}

/**
* Refreshes an access token, and returns the new access token.
*
* @param AccessTokenInterface $accessToken
* @return AccessTokenInterface
* @throws IdentityProviderException
*/
public function refreshAccessToken(AccessTokenInterface $accessToken)
{
return $this->getProvider()->getAccessToken('refresh_token', [
'refresh_token' => $accessToken->getRefreshToken()
]);
}
}
17 changes: 0 additions & 17 deletions src/Providers/XeroLaravelServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

use Illuminate\Support\ServiceProvider;
use LangleyFoxall\XeroLaravel\Constants;
use LangleyFoxall\XeroLaravel\Facades\Xero;

class XeroLaravelServiceProvider extends ServiceProvider
{
Expand All @@ -17,10 +16,6 @@ public function register()
$this->mergeConfigFrom(
Constants::CONFIG_PATH, Constants::CONFIG_KEY
);

$this->app->singleton('Xero', function () {
return (new \LangleyFoxall\XeroLaravel\Xero())->app();
});
}

/**
Expand All @@ -34,16 +29,4 @@ public function boot()
Constants::CONFIG_PATH => config_path(Constants::CONFIG_KEY.'.php'),
]);
}

/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{
return [
Xero::class,
];
}
}
Loading

0 comments on commit e486e18

Please sign in to comment.