From 0b64fd83d180781663ec673559e3a94a2f22d04e Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:04:26 +0000 Subject: [PATCH 01/19] Initial commit of prior OAuth 2 upgrade work --- README.md | 102 +++++++ src/Exceptions/InvalidConfigException.php | 8 + .../InvalidOAuth2StateException.php | 8 + src/OAuth2.php | 270 ++++++++++++++++++ src/config/xero-laravel-lf.php | 7 +- 5 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/InvalidConfigException.php create mode 100644 src/Exceptions/InvalidOAuth2StateException.php create mode 100644 src/OAuth2.php diff --git a/README.md b/README.md index 7a58e82..fdbaae5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,108 @@ XERO_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XERO_TENANT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` +### OAuth2 authentication flow + +If multiple users are going to be using your application, or if you need to dynamically fetch the access tokens you +can use the built in authentication flow helper. This handles URI generation and redirects then allowing you to gain access +to the token(s) without any unwanted mutations. + +* A [`Illuminate\Http\RedirectResponse`](https://laravel.com/api/6.x/Illuminate/Http/RedirectResponse.html) is returned from `redirect`. +* A [`League\OAuth2\Client\Token\AccessTokenInterface`](https://github.com/thephpleague/oauth2-client/blob/master/src/Token/AccessTokenInterface.php) is returned from `getToken`. + +#### Usage + +Instantiate `OAuth2` in a controller and pass in a Request, call `redirect` and then `getToken`. + +```php +redirect()) { + $token = $oauth->getToken(); + + // Deal with token + dd($token); + } + + return $response; + } +} +``` + +By default environment variables will be used: +``` +XERO_CLIENT_ID= +XERO_CLIENT_SECRET= +XERO_REDIRECT_URI= +``` + +We pre-define the least amount of scopes for the authentication (`openid email profile`) but you can change these +by adding `XERO_SCOPE` to your `.env` file. You can find a list of available scopes [here](https://developer.xero.com/documentation/oauth2/scopes). + +If you need to define the `client_id`, `client_secret`, `redirect_uri` or `scope` on the fly you can do so by +calling the following methods before `redirect`: + +```php +setClientId('XXXX') + ->setClientSecret('XXXX') + ->setRedirectUri('XXXX') + ->setScope('XXXX'); + + if (!$response = $oauth->redirect()) { + $token = $oauth->getToken(); + + // Deal with token + dd($token); + } + + return $response; + } +} +``` + ## Migration from 1.x/OAuth 1a There is now only one flow for all applications, which is most similar to the legacy Public application. diff --git a/src/Exceptions/InvalidConfigException.php b/src/Exceptions/InvalidConfigException.php new file mode 100644 index 0000000..01a501c --- /dev/null +++ b/src/Exceptions/InvalidConfigException.php @@ -0,0 +1,8 @@ + 'oauth2state', + 'REQUEST_STATE' => 'state', + 'REQUEST_CODE' => 'code', + ]; + + /** @var Request $request */ + protected $request; + + /** @var string $key */ + protected $key; + + /** @var string $clientId */ + protected $clientId; + + /** @var string $clientSecret */ + protected $clientSecret; + + /** @var string $redirectUri */ + protected $redirectUri; + + /** @var string $scope */ + protected $scope; + + /** @var Provider $provider */ + protected $provider; + + /** @var AccessTokenInterface $token */ + protected $token; + + /** + * @param Request $request + * @param string $key + */ + public function __construct(Request $request, string $key = 'default') + { + $this->request = $request; + $this->key = $key; + + $this->bootstrap(); + } + + /** + * Bootstrap the OAuth2 flow by using config values. + * + * @return void + */ + protected function bootstrap() + { + $this->startSession(); + + $key = $this->key; + $config = config(Constants::CONFIG_KEY); + + if (isset($config['apps'][$key])) { + $app = $config['apps'][$key]; + + $this->setClientId($app['client_id'] ?? ''); + $this->setClientSecret($app['client_secret'] ?? ''); + $this->setRedirectUri($app['redirect_uri'] ?? ''); + $this->setScope($app['scope'] ?? ''); + } + } + + /** + * Get the OAuth2 provider. + * + * @param bool $new + * @return Provider + * @throws InvalidConfigException + */ + public function getProvider($new = false) + { + if (! empty($provider = $this->provider) && ! $new) { + return $provider; + } + + $this->validateConfig(); + + return $this->provider = new Provider([ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + 'redirectUri' => $this->redirectUri, + ]); + } + + /** + * Get the authentication URL. + * + * @return string + * @throws InvalidConfigException + */ + public function getAuthUri() + { + $provider = $this->getProvider(); + $scope = $this->scope; + + return $provider->getAuthorizationUrl(compact( + 'scope' + )); + } + + /** + * Get the token. + * + * @param bool $new + * @return AccessTokenInterface + * @throws IdentityProviderException + * @throws InvalidConfigException + * @throws InvalidOAuth2StateException + */ + public function getToken($new = false) + { + if (! empty($token = $this->token) && ! $new) { + return $token; + } + + $provider = $this->getProvider(); + $request = $this->request; + + $sessionState = session()->get(self::KEYS['SESSION_STATE']); + $requestState = $request->get(self::KEYS['REQUEST_STATE']); + $code = $request->get(self::KEYS['REQUEST_CODE']); + + // Check that state hasn't been tampered with + if ((empty($requestState) || ($requestState !== $sessionState))) { + unset($sessionState); + + throw new InvalidOAuth2StateException; + } + + return $this->token = $provider->getAccessToken('authorization_code', compact( + 'code' + )); + } + + /** + * Manually set the client ID. + * + * @param string $clientId + * @return $this + */ + public function setClientId(string $clientId) + { + $this->clientId = $clientId; + + return $this; + } + + /** + * Manually set the client secret. + * + * @param string $clientSecret + * @return $this + */ + public function setClientSecret(string $clientSecret) + { + $this->clientSecret = $clientSecret; + + return $this; + } + + /** + * Manually set the redirect URI. + * + * @param string $redirectUri + * @return $this + */ + public function setRedirectUri(string $redirectUri) + { + $this->redirectUri = $redirectUri; + + return $this; + } + + /** + * Manually set the scope. + * + * @param string $scope + * @return $this + */ + public function setScope(string $scope) + { + $this->scope = trim($scope); + + return $this; + } + + /** + * Handle the redirect flow for OAuth2. + * + * @return bool|RedirectResponse|Redirector + * @throws InvalidConfigException + */ + public function redirect() + { + $request = $this->request; + + if (! empty($request->get(self::KEYS['REQUEST_CODE']))) { + return false; + } + + $authUri = $this->getAuthUri(); + + session()->put( + self::KEYS['SESSION_STATE'], + $this->getProvider()->getState() + ); + + return redirect($authUri); + } + + /** + * Start a session if one has not already been started. + * + * @return void + */ + protected function startSession() + { + if (! session()->isStarted()) { + session()->start(); + } + } + + /** + * Validate OAuth2 configuration settings. + * + * @return void + * @throws InvalidConfigException + */ + protected function validateConfig() + { + $key = $this->key; + + if (empty($this->clientId)) { + throw new InvalidConfigException('A client ID is required for the OAuth2 flow. Set `client_id` in '.$key.' app\'s configuration or call `setClientId` before `redirect`.'); + } + + if (empty($this->clientSecret)) { + throw new InvalidConfigException('A client secret is required for the OAuth2 flow. Set `client_secret` in '.$key.' app\'s configuration or call `setClientSecret` before `redirect`.'); + } + + if (empty($this->redirectUri)) { + throw new InvalidConfigException('A redirect URI is required for the OAuth2 flow. Set `redirect_uri` in '.$key.' app\'s configuration or call `setRedirectUri` before `redirect`.'); + } + + if (empty($this->scope)) { + throw new InvalidConfigException('A scope is required for the OAuth2 flow. Set `scope` in '.$key.' app\'s configuration or call `setScope` before `redirect`.'); + } + } +} diff --git a/src/config/xero-laravel-lf.php b/src/config/xero-laravel-lf.php index 692624f..949f432 100644 --- a/src/config/xero-laravel-lf.php +++ b/src/config/xero-laravel-lf.php @@ -14,8 +14,13 @@ 'apps' => [ 'default' => [ - 'token' => env('XERO_TOKEN'), + 'token' => env('XERO_TOKEN'), 'tenant_id' => env('XERO_TENANT_ID'), + + 'client_id' => env('XERO_CLIENT_ID'), + 'client_secret' => env('XERO_CLIENT_SECRET'), + 'redirect_uri' => env('XERO_REDIRECT_URI'), + 'scope' => env('XERO_SCOPE', 'openid email profile'), ], ], ]; From 1d59efef79db3e32cdb27bd141ae0f852d576594 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:06:05 +0000 Subject: [PATCH 02/19] Remove Facade --- README.md | 12 ------------ src/Facades/Xero.php | 18 ------------------ src/Providers/XeroLaravelServiceProvider.php | 16 ---------------- 3 files changed, 46 deletions(-) delete mode 100644 src/Facades/Xero.php diff --git a/README.md b/README.md index fdbaae5..a394193 100644 --- a/README.md +++ b/README.md @@ -175,18 +175,6 @@ $xero = (new Xero())->app(); # To use the 'default' app in the config $xero = (new Xero())->app('foobar'); # To use a custom app called 'foobar' in the config file ``` -Alternately you can use the Xero facade -*Note this is only for the default config* -```php -use LangleyFoxall\XeroLaravel\Facades\Xero; - -# Retrieve all contacts via facade -$contacts = Xero::contacts()->get(); - -# Retrieve an individual contact by its GUID -$contact = Xero::contacts()->find('34xxxx6e-7xx5-2xx4-bxx5-6123xxxxea49'); -``` - You can then immediately access Xero data using Eloquent-like syntax. The following code snippet shows the available syntax. When multiple results are returned from the API they will be returned as Laravel Collection. diff --git a/src/Facades/Xero.php b/src/Facades/Xero.php deleted file mode 100644 index 751924d..0000000 --- a/src/Facades/Xero.php +++ /dev/null @@ -1,18 +0,0 @@ -mergeConfigFrom( Constants::CONFIG_PATH, Constants::CONFIG_KEY ); - - $this->app->singleton('Xero', function () { - return (new \LangleyFoxall\XeroLaravel\Xero())->app(); - }); } /** @@ -35,15 +30,4 @@ public function boot() ]); } - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides() - { - return [ - Xero::class, - ]; - } } From 9210f20d874110db8100a3e820a1d6117d5f1144 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:41:15 +0000 Subject: [PATCH 03/19] Remove token and tenant id from config --- src/config/xero-laravel-lf.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/config/xero-laravel-lf.php b/src/config/xero-laravel-lf.php index 949f432..e6983c1 100644 --- a/src/config/xero-laravel-lf.php +++ b/src/config/xero-laravel-lf.php @@ -14,13 +14,10 @@ 'apps' => [ 'default' => [ - 'token' => env('XERO_TOKEN'), - 'tenant_id' => env('XERO_TENANT_ID'), - 'client_id' => env('XERO_CLIENT_ID'), 'client_secret' => env('XERO_CLIENT_SECRET'), 'redirect_uri' => env('XERO_REDIRECT_URI'), - 'scope' => env('XERO_SCOPE', 'openid email profile'), + 'scope' => env('XERO_SCOPE', 'openid email profile offline_access'), ], ], ]; From e245ddd946e71f2dc2170da06b23616c46867961 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:41:27 +0000 Subject: [PATCH 04/19] Specify dependencies --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 167c7d9..cbd60c9 100644 --- a/composer.json +++ b/composer.json @@ -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": { From 28a71eb4670046866cf7af75a3332a16f412197d Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:42:20 +0000 Subject: [PATCH 05/19] Remove setters as these will come from app config --- src/OAuth2.php | 52 -------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 7750af1..ad29227 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -153,58 +153,6 @@ public function getToken($new = false) )); } - /** - * Manually set the client ID. - * - * @param string $clientId - * @return $this - */ - public function setClientId(string $clientId) - { - $this->clientId = $clientId; - - return $this; - } - - /** - * Manually set the client secret. - * - * @param string $clientSecret - * @return $this - */ - public function setClientSecret(string $clientSecret) - { - $this->clientSecret = $clientSecret; - - return $this; - } - - /** - * Manually set the redirect URI. - * - * @param string $redirectUri - * @return $this - */ - public function setRedirectUri(string $redirectUri) - { - $this->redirectUri = $redirectUri; - - return $this; - } - - /** - * Manually set the scope. - * - * @param string $scope - * @return $this - */ - public function setScope(string $scope) - { - $this->scope = trim($scope); - - return $this; - } - /** * Handle the redirect flow for OAuth2. * From fa091e2f63a0937505248fcbd20bcb155e8a48db Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 09:49:09 +0000 Subject: [PATCH 06/19] Remove Xero helper file --- src/Xero.php | 44 -------------------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 src/Xero.php diff --git a/src/Xero.php b/src/Xero.php deleted file mode 100644 index f18084c..0000000 --- a/src/Xero.php +++ /dev/null @@ -1,44 +0,0 @@ -apps[$key])) { - $this->apps[$key] = $this->createApp($key); - } - - return $this->apps[$key]; - } - - /** - * Creates the XeroApp object - * - * @param string $key - * @return XeroApp - * @throws Exception - */ - private function createApp($key) - { - $config = config(Constants::CONFIG_KEY); - - if (! isset($config['apps'][$key])) { - throw new Exception('The specified key could not be found in the Xero \'apps\' array, ' . - 'or the \'apps\' array does not exist.'); - } - - return new XeroApp($config['apps'][$key]['token'], $config['apps'][$key]['tenant_id']); - } -} From f4f49371dfa79715fb19f5c43b3cb8f7d493334d Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 10:46:03 +0000 Subject: [PATCH 07/19] Rework OAuth2 helper and XeroApp construction --- ...on.php => InvalidXeroRequestException.php} | 2 +- src/OAuth2.php | 190 ++++++------------ src/XeroApp.php | 10 +- 3 files changed, 67 insertions(+), 135 deletions(-) rename src/Exceptions/{InvalidConfigException.php => InvalidXeroRequestException.php} (55%) diff --git a/src/Exceptions/InvalidConfigException.php b/src/Exceptions/InvalidXeroRequestException.php similarity index 55% rename from src/Exceptions/InvalidConfigException.php rename to src/Exceptions/InvalidXeroRequestException.php index 01a501c..64dc860 100644 --- a/src/Exceptions/InvalidConfigException.php +++ b/src/Exceptions/InvalidXeroRequestException.php @@ -2,7 +2,7 @@ namespace LangleyFoxall\XeroLaravel\Exceptions; -class InvalidConfigException extends \Exception +class InvalidXeroRequestException extends \Exception { // } diff --git a/src/OAuth2.php b/src/OAuth2.php index ad29227..41b18db 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -3,32 +3,22 @@ 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 LangleyFoxall\XeroLaravel\Exceptions\InvalidConfigException; +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; -use function compact; -use function config; -use function redirect; -use function session; class OAuth2 { const KEYS = [ - 'SESSION_STATE' => 'oauth2state', - 'REQUEST_STATE' => 'state', - 'REQUEST_CODE' => 'code', + 'SESSION_STATE' => 'xero-oauth-2-session-state', ]; - /** @var Request $request */ - protected $request; - - /** @var string $key */ - protected $key; - /** @var string $clientId */ protected $clientId; @@ -48,171 +38,111 @@ class OAuth2 protected $token; /** - * @param Request $request - * @param string $key + * @param string $key */ - public function __construct(Request $request, string $key = 'default') + public function __construct(string $key = 'default') { - $this->request = $request; - $this->key = $key; - - $this->bootstrap(); - } - - /** - * Bootstrap the OAuth2 flow by using config values. - * - * @return void - */ - protected function bootstrap() - { - $this->startSession(); - - $key = $this->key; $config = config(Constants::CONFIG_KEY); - if (isset($config['apps'][$key])) { - $app = $config['apps'][$key]; - - $this->setClientId($app['client_id'] ?? ''); - $this->setClientSecret($app['client_secret'] ?? ''); - $this->setRedirectUri($app['redirect_uri'] ?? ''); - $this->setScope($app['scope'] ?? ''); + 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. * - * @param bool $new * @return Provider - * @throws InvalidConfigException */ - public function getProvider($new = false) + private function getProvider() { - if (! empty($provider = $this->provider) && ! $new) { - return $provider; + if (!$this->provider) { + $this->provider = new Provider([ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + 'redirectUri' => $this->redirectUri, + ]); } - $this->validateConfig(); - - return $this->provider = new Provider([ - 'clientId' => $this->clientId, - 'clientSecret' => $this->clientSecret, - 'redirectUri' => $this->redirectUri, - ]); + return $this->provider; } /** - * Get the authentication URL. + * Get a redirect to the Xero authorization URL. * - * @return string - * @throws InvalidConfigException + * @return RedirectResponse|Redirector */ - public function getAuthUri() + public function getAuthorizationRedirect() { $provider = $this->getProvider(); - $scope = $this->scope; - return $provider->getAuthorizationUrl(compact( - 'scope' - )); + $authUri = $provider->getAuthorizationUrl(['scope' => $this->scope]); + + session()->put(self::KEYS['SESSION_STATE'], $provider->getState()); + + return redirect($authUri); } /** - * Get the token. + * Handle the incoming request from Xero, request an access token and return it. * - * @param bool $new + * @param Request $request * @return AccessTokenInterface * @throws IdentityProviderException - * @throws InvalidConfigException * @throws InvalidOAuth2StateException + * @throws InvalidXeroRequestException */ - public function getToken($new = false) + public function getAccessTokenFromXeroRequest(Request $request) { - if (! empty($token = $this->token) && ! $new) { - return $token; - } - - $provider = $this->getProvider(); - $request = $this->request; - - $sessionState = session()->get(self::KEYS['SESSION_STATE']); - $requestState = $request->get(self::KEYS['REQUEST_STATE']); - $code = $request->get(self::KEYS['REQUEST_CODE']); - - // Check that state hasn't been tampered with - if ((empty($requestState) || ($requestState !== $sessionState))) { - unset($sessionState); + $code = $request->get('code'); + $state = $request->get('state'); - throw new InvalidOAuth2StateException; + if (!$code) { + throw new InvalidXeroRequestException('No `code` present is request from Xero.'); } - return $this->token = $provider->getAccessToken('authorization_code', compact( - 'code' - )); - } - - /** - * Handle the redirect flow for OAuth2. - * - * @return bool|RedirectResponse|Redirector - * @throws InvalidConfigException - */ - public function redirect() - { - $request = $this->request; - - if (! empty($request->get(self::KEYS['REQUEST_CODE']))) { - return false; + if (!$state) { + throw new InvalidXeroRequestException('No `code` present is request from Xero.'); } - $authUri = $this->getAuthUri(); - - session()->put( - self::KEYS['SESSION_STATE'], - $this->getProvider()->getState() - ); + if ($state !== session(self::KEYS['SESSION_STATE'])) { + throw new InvalidOAuth2StateException('Invalid `state`. Request may have been tampered with.'); + } - return redirect($authUri); + return $this->getProvider()->getAccessToken('authorization_code', ['code' => $code]); } /** - * Start a session if one has not already been started. + * Get all the tenants (Xero organisations) that the access token is able to access. * - * @return void + * @param AccessTokenInterface $accessToken + * @return XeroTenant[] + * @throws IdentityProviderException */ - protected function startSession() + public function getTenants(AccessTokenInterface $accessToken) { - if (! session()->isStarted()) { - session()->start(); - } + return $this->provider->getTenants($accessToken); } /** - * Validate OAuth2 configuration settings. + * Refreshes an access token, and returns the new access token. * - * @return void - * @throws InvalidConfigException + * @param AccessTokenInterface $accessToken + * @return AccessTokenInterface + * @throws IdentityProviderException */ - protected function validateConfig() + public function refreshAccessToken(AccessTokenInterface $accessToken) { - $key = $this->key; - - if (empty($this->clientId)) { - throw new InvalidConfigException('A client ID is required for the OAuth2 flow. Set `client_id` in '.$key.' app\'s configuration or call `setClientId` before `redirect`.'); - } - - if (empty($this->clientSecret)) { - throw new InvalidConfigException('A client secret is required for the OAuth2 flow. Set `client_secret` in '.$key.' app\'s configuration or call `setClientSecret` before `redirect`.'); - } - - if (empty($this->redirectUri)) { - throw new InvalidConfigException('A redirect URI is required for the OAuth2 flow. Set `redirect_uri` in '.$key.' app\'s configuration or call `setRedirectUri` before `redirect`.'); - } - - if (empty($this->scope)) { - throw new InvalidConfigException('A scope is required for the OAuth2 flow. Set `scope` in '.$key.' app\'s configuration or call `setScope` before `redirect`.'); - } + return $this->getProvider()->getAccessToken('refresh_token', [ + 'refresh_token' => $accessToken->getRefreshToken() + ]); } + } diff --git a/src/XeroApp.php b/src/XeroApp.php index 5f723aa..77ead47 100644 --- a/src/XeroApp.php +++ b/src/XeroApp.php @@ -2,8 +2,10 @@ namespace LangleyFoxall\XeroLaravel; +use Calcinai\OAuth2\Client\XeroTenant; use Exception; use LangleyFoxall\XeroLaravel\Traits\HasXeroRelationships; +use League\OAuth2\Client\Token\AccessTokenInterface; use XeroPHP\Application; /** @@ -18,13 +20,13 @@ class XeroApp extends Application /** * XeroApp constructor. * - * @param $token - * @param $tenantId + * @param AccessTokenInterface $accessToken + * @param XeroTenant $tenant * @throws Exception */ - public function __construct($token, $tenantId) + public function __construct(AccessTokenInterface $accessToken, XeroTenant $tenant) { - parent::__construct($token, $tenantId); + parent::__construct($accessToken->getToken(), $tenant->id); $this->populateRelationshipToModelMaps(); } From a5551a2e849cfef1d7bf2c012565967eb14a1aad Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 10:55:07 +0000 Subject: [PATCH 08/19] Remove unnecessary storage of provider and token in OAuth2 helper --- src/OAuth2.php | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 41b18db..c77d84b 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -31,11 +31,6 @@ class OAuth2 /** @var string $scope */ protected $scope; - /** @var Provider $provider */ - protected $provider; - - /** @var AccessTokenInterface $token */ - protected $token; /** * @param string $key @@ -63,15 +58,11 @@ public function __construct(string $key = 'default') */ private function getProvider() { - if (!$this->provider) { - $this->provider = new Provider([ - 'clientId' => $this->clientId, - 'clientSecret' => $this->clientSecret, - 'redirectUri' => $this->redirectUri, - ]); - } - - return $this->provider; + return new Provider([ + 'clientId' => $this->clientId, + 'clientSecret' => $this->clientSecret, + 'redirectUri' => $this->redirectUri, + ]); } /** From c07200457575b6a0135badc554ced4433224e4aa Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 11:00:17 +0000 Subject: [PATCH 09/19] Do not put scope in env() as it is very unlikely to change between environments --- src/config/xero-laravel-lf.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/xero-laravel-lf.php b/src/config/xero-laravel-lf.php index e6983c1..b71c033 100644 --- a/src/config/xero-laravel-lf.php +++ b/src/config/xero-laravel-lf.php @@ -17,7 +17,7 @@ 'client_id' => env('XERO_CLIENT_ID'), 'client_secret' => env('XERO_CLIENT_SECRET'), 'redirect_uri' => env('XERO_REDIRECT_URI'), - 'scope' => env('XERO_SCOPE', 'openid email profile offline_access'), + 'scope' =>'openid email profile offline_access', ], ], ]; From 6421b2525d1bdd8d94ce5213a617c0fa28ff3fdd Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 11:03:00 +0000 Subject: [PATCH 10/19] Improve comments --- src/OAuth2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index c77d84b..ba4eb7a 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -111,7 +111,7 @@ public function getAccessTokenFromXeroRequest(Request $request) } /** - * Get all the tenants (Xero organisations) that the access token is able to access. + * Get all the tenants (typically Xero organisations) that the access token is able to access. * * @param AccessTokenInterface $accessToken * @return XeroTenant[] From cd7d9abb0ea5c76c1a571a227866f768fd8cb07c Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 12:47:51 +0000 Subject: [PATCH 11/19] Fix incorrect call to provider --- src/OAuth2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index ba4eb7a..fb04928 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -119,7 +119,7 @@ public function getAccessTokenFromXeroRequest(Request $request) */ public function getTenants(AccessTokenInterface $accessToken) { - return $this->provider->getTenants($accessToken); + return $this->getProvider()->getTenants($accessToken); } /** From f9a1ce09341dcd1dec2966976bd37e257e5d4a45 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 13:55:06 +0000 Subject: [PATCH 12/19] Switch to using just tenant ID --- src/XeroApp.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/XeroApp.php b/src/XeroApp.php index 77ead47..06b13c7 100644 --- a/src/XeroApp.php +++ b/src/XeroApp.php @@ -21,12 +21,12 @@ class XeroApp extends Application * XeroApp constructor. * * @param AccessTokenInterface $accessToken - * @param XeroTenant $tenant + * @param string $tenantId * @throws Exception */ - public function __construct(AccessTokenInterface $accessToken, XeroTenant $tenant) + public function __construct(AccessTokenInterface $accessToken, string $tenantId) { - parent::__construct($accessToken->getToken(), $tenant->id); + parent::__construct($accessToken->getToken(), $tenantId); $this->populateRelationshipToModelMaps(); } From ebe65cb28464512b78da14f11917ee6c0f15dbe2 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:07:53 +0000 Subject: [PATCH 13/19] Rewrite documentation for OAuth 2.0 --- README.md | 158 +++++++++++++++++++++++------------------------------- 1 file changed, 66 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index a394193..8bafbb9 100644 --- a/README.md +++ b/README.md @@ -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. +

@@ -48,131 +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 ``` -### OAuth2 authentication flow - -If multiple users are going to be using your application, or if you need to dynamically fetch the access tokens you -can use the built in authentication flow helper. This handles URI generation and redirects then allowing you to gain access -to the token(s) without any unwanted mutations. +### OAuth 2.0 flow -* A [`Illuminate\Http\RedirectResponse`](https://laravel.com/api/6.x/Illuminate/Http/RedirectResponse.html) is returned from `redirect`. -* A [`League\OAuth2\Client\Token\AccessTokenInterface`](https://github.com/thephpleague/oauth2-client/blob/master/src/Token/AccessTokenInterface.php) is returned from `getToken`. +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. -Instantiate `OAuth2` in a controller and pass in a Request, call `redirect` and then `getToken`. +The controller below shows these steps in action. ```php redirect()) { - $token = $oauth->getToken(); - - // Deal with token - dd($token); - } - - return $response; + // 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(); } -} -``` - -By default environment variables will be used: -``` -XERO_CLIENT_ID= -XERO_CLIENT_SECRET= -XERO_REDIRECT_URI= -``` -We pre-define the least amount of scopes for the authentication (`openid email profile`) but you can change these -by adding `XERO_SCOPE` to your `.env` file. You can find a list of available scopes [here](https://developer.xero.com/documentation/oauth2/scopes). - -If you need to define the `client_id`, `client_secret`, `redirect_uri` or `scope` on the fly you can do so by -calling the following methods before `redirect`: - -```php -getOAuth2()->getAuthorizationRedirect(); + } -class OAuthController extends Controller -{ - /** - * @param Request $request - * @return RedirectResponse|void - * @throws InvalidConfigException - * @throws InvalidOAuth2StateException - * @throws IdentityProviderException - */ - public function __invoke(Request $request) + public function handleCallbackFromXero(Request $request) { - $oauth = new OAuth2($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(); + } - $oauth - ->setClientId('XXXX') - ->setClientSecret('XXXX') - ->setRedirectUri('XXXX') - ->setScope('XXXX'); + 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 (!$response = $oauth->redirect()) { - $token = $oauth->getToken(); + if ($accessToken->hasExpired()) { + $accessToken = $this->getOAuth2()->refreshAccessToken($accessToken); - // Deal with token - dd($token); + $user->xero_access_token = $accessToken; + $user->save(); } - - return $response; } } ``` -## Migration from 1.x/OAuth 1a +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. -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. +Xero's documentation provides a full [list of available scopes](https://developer.xero.com/documentation/oauth2/scopes). -Following [this example](https://github.com/calcinai/xero-php#authorization-code-flow) you can generate the -required token and tenant id. - -For more information on scopes try the [xero documentation](https://developer.xero.com/documentation/oauth2/scopes). ## Usage -To use Xero Laravel, you first need to get retrieve an instance of your Xero -app. This can be done as shown below. +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 -$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 +use LangleyFoxall\XeroLaravel\XeroApp; +use League\OAuth2\Client\Token\AccessToken; + +$user = auth()->user(); + +$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 From 1f339a567d9eda63fea21425e50a5ae61539a767 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:26:51 +0000 Subject: [PATCH 14/19] Style fixes --- src/OAuth2.php | 1 - src/Providers/XeroLaravelServiceProvider.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index fb04928..0a0bbba 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -135,5 +135,4 @@ public function refreshAccessToken(AccessTokenInterface $accessToken) 'refresh_token' => $accessToken->getRefreshToken() ]); } - } diff --git a/src/Providers/XeroLaravelServiceProvider.php b/src/Providers/XeroLaravelServiceProvider.php index 6824bfa..714f412 100644 --- a/src/Providers/XeroLaravelServiceProvider.php +++ b/src/Providers/XeroLaravelServiceProvider.php @@ -29,5 +29,4 @@ public function boot() Constants::CONFIG_PATH => config_path(Constants::CONFIG_KEY.'.php'), ]); } - } From f763c98682a9050cd6b32799d8e0434d5a2f8752 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:32:34 +0000 Subject: [PATCH 15/19] Fix typo --- src/OAuth2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 0a0bbba..1db90d3 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -100,7 +100,7 @@ public function getAccessTokenFromXeroRequest(Request $request) } if (!$state) { - throw new InvalidXeroRequestException('No `code` present is request from Xero.'); + throw new InvalidXeroRequestException('No `state` present is request from Xero.'); } if ($state !== session(self::KEYS['SESSION_STATE'])) { From be7f7f6c58931e6f059ca5cc89b6c574a125c015 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:32:59 +0000 Subject: [PATCH 16/19] Request typos --- src/OAuth2.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index 1db90d3..fc882cd 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -96,11 +96,11 @@ public function getAccessTokenFromXeroRequest(Request $request) $state = $request->get('state'); if (!$code) { - throw new InvalidXeroRequestException('No `code` present is request from Xero.'); + throw new InvalidXeroRequestException('No `code` present in request from Xero.'); } if (!$state) { - throw new InvalidXeroRequestException('No `state` present is request from Xero.'); + throw new InvalidXeroRequestException('No `state` present in request from Xero.'); } if ($state !== session(self::KEYS['SESSION_STATE'])) { From f13e03f49629e057f1eeba15f6db5033a66f81d7 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:33:28 +0000 Subject: [PATCH 17/19] Update docblock comment --- src/OAuth2.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index fc882cd..d91078d 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -31,8 +31,9 @@ class OAuth2 /** @var string $scope */ protected $scope; - /** + * OAuth2 constructor. + * * @param string $key */ public function __construct(string $key = 'default') From 5d8bf5bcf928f4925553f8525b65e5bc8b259e74 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Fri, 27 Mar 2020 16:34:32 +0000 Subject: [PATCH 18/19] Style fix --- src/OAuth2.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OAuth2.php b/src/OAuth2.php index d91078d..d5ae93d 100644 --- a/src/OAuth2.php +++ b/src/OAuth2.php @@ -33,7 +33,7 @@ class OAuth2 /** * OAuth2 constructor. - * + * * @param string $key */ public function __construct(string $key = 'default') From 0d77c51b2eb8fb7983d65a54a0ed3003d066c68f Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 30 Mar 2020 00:20:15 +0100 Subject: [PATCH 19/19] Update src/config/xero-laravel-lf.php Co-Authored-By: Dexter Marks-Barber --- src/config/xero-laravel-lf.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/xero-laravel-lf.php b/src/config/xero-laravel-lf.php index b71c033..11362a4 100644 --- a/src/config/xero-laravel-lf.php +++ b/src/config/xero-laravel-lf.php @@ -17,7 +17,7 @@ 'client_id' => env('XERO_CLIENT_ID'), 'client_secret' => env('XERO_CLIENT_SECRET'), 'redirect_uri' => env('XERO_REDIRECT_URI'), - 'scope' =>'openid email profile offline_access', + 'scope' => 'openid email profile offline_access', ], ], ];