calcinai/oauth2-xero
OAuth 2.0 provider for Xero built on the League OAuth2 Client. Supports the authorization code flow, scope configuration, fetching the authenticated user (OpenID) and retrieving authorized Xero tenants for making API requests.
composer require calcinai/oauth2-xero
clientId, clientSecret, and set a redirectUri.use Calcinai\OAuth2\Client\Provider\Xero;
$provider = new Xero([
'clientId' => env('XERO_CLIENT_ID'),
'clientSecret' => env('XERO_CLIENT_SECRET'),
'redirectUri' => env('XERO_REDIRECT_URI'),
]);
$authUrl = $provider->getAuthorizationUrl([
'scope' => 'openid email profile accounting.transactions',
]);
header('Location: ' . $authUrl);
Handle the callback in a Laravel route/controller to exchange the code for a token and fetch user/tenant data.\Calcinai\OAuth2\Client\Provider\Xero (extends League’s AbstractProvider).session() helper for CSRF state validation (as shown in the example).Route::get('/xero/auth', function () {
$provider = resolve(Xero::class);
$authUrl = $provider->getAuthorizationUrl(['scope' => 'accounting.transactions']);
session(['oauth2state' => $provider->getState()]);
return redirect($authUrl);
});
Route::get('/xero/callback', function (Request $request) {
$provider = resolve(Xero::class);
if (!session('oauth2state') || $request->state !== session('oauth2state')) {
throw new \Exception('Invalid state');
}
$token = $provider->getAccessToken('authorization_code', [
'code' => $request->code,
]);
$user = $provider->getResourceOwner($token);
$tenants = $provider->getTenants($token);
// Store token/tenants in session or database
});
scheduler or queue:
$refreshToken = $storedToken->refresh_token;
$newToken = $provider->getAccessToken('refresh_token', [
'refresh_token' => $refreshToken,
]);
$storedToken->update(['access_token' => $newToken->getToken(), 'expires' => $newToken->getExpires()]);
$tenants = $provider->getTenants($token);
foreach ($tenants as $tenant) {
$api = new XeroAPI($tenant->tenantId, $token);
$invoices = $api->getInvoices();
}
$provider = new Xero([
'clientId' => env('XERO_CLIENT_ID'),
'redirectUri' => env('XERO_REDIRECT_URI'),
'usePKCE' => true, // Check if this option exists in the package
]);
access_token, refresh_token, and expires in the database.getTenants() to list accessible orgs.database or cache to persist tokens.
$tokenData = [
'access_token' => $token->getToken(),
'refresh_token' => $token->getRefreshToken(),
'expires' => $token->getExpires(),
'scopes' => $token->getScopes(),
];
Token::updateOrCreate(['user_id' => auth()->id()], $tokenData);
if (Carbon::now()->gt(Carbon::parse($token->expires))) {
$newToken = $provider->getAccessToken('refresh_token', [
'refresh_token' => $token->refresh_token,
]);
// Update stored token
}
// app/Providers/XeroServiceProvider.php
public function register()
{
$this->app->singleton(Xero::class, function ($app) {
return new \Calcinai\OAuth2\Client\Provider\Xero([
'clientId' => config('services.xero.client_id'),
'clientSecret' => config('services.xero.client_secret'),
'redirectUri' => config('services.xero.redirect_uri'),
]);
});
}
accounting.transactions instead of openid if not needed).League\OAuth2\Client\Provider\Exception\IdentityProviderException).try {
$token = $provider->getAccessToken('authorization_code', [...]);
} catch (\Exception $e) {
\Log::error('Xero OAuth Error', ['error' => $e->getMessage()]);
throw $e;
}
Http and Session facades to mock OAuth flows in tests:
$this->get('/xero/auth')->assertRedirect();
$this->get('/xero/callback?code=test&state=' . session('oauth2state'))
->assertSessionHas('xero_token');
$response = Http::withToken($token->getToken())
->get('https://api.xero.com/api.xro/2.0/Invoices');
State Validation:
if (empty($_GET['state']) || ($_GET['state'] !== $_SESSION['oauth2state'])) {
exit('Invalid state');
}
session() helper or middleware to manage state.Token Expiry:
PKCE Limitations:
Tenant Context:
getTenants() returns all accessible orgs, but API calls require a specific tenant ID.auth()->user()->update(['xero_tenant_id' => $tenant->tenantId]);
Scope Restrictions:
Redirect URI Mismatch:
redirectUri must exactly match the registered URI (including http vs. https).if ($provider->getRedirectUri() !== env('XERO_REDIRECT_URI')) {
throw new \Exception('Redirect URI mismatch');
}
How can I help you explore Laravel packages today?