OmniAuth Xero OAuth2 Strategy
An OmniAuth strategy for authenticating with Xero using OAuth 2.0.
Compatible with OmniAuth 2.0+ - This gem is designed for modern Rails applications using OmniAuth 2.0 or later with multi-tenant support.
Installation
Add this line to your application's Gemfile:
gem 'omniauth-xero-oauth2-modern'Then execute:
$ bundle installOr install it yourself:
$ gem install omniauth-xero-oauth2-modernXero Developer Setup
- Go to the Xero Developer Portal
- Create a new app or select an existing one
- Note your Client ID and Client Secret
- Configure your Redirect URIs (e.g.,
https://yourapp.com/auth/xero_oauth2_modern/callback)
For more details, read the Xero OAuth 2.0 documentation.
Usage
Standalone OmniAuth
Add the middleware to your application in config/initializers/omniauth.rb:
Rails.application.config.middleware.use OmniAuth::Builder do
provider :xero_oauth2_modern,
ENV['XERO_CLIENT_ID'],
ENV['XERO_CLIENT_SECRET'],
scope: 'openid profile email accounting.transactions accounting.contacts accounting.settings'
end
# Required for OmniAuth 2.0+
OmniAuth.config.allowed_request_methods = %i[get post]You can now access the OmniAuth Xero URL at /auth/xero_oauth2_modern.
With Devise
Add the provider to your Devise configuration in config/initializers/devise.rb:
config.omniauth :xero_oauth2_modern,
ENV['XERO_CLIENT_ID'],
ENV['XERO_CLIENT_SECRET'],
scope: 'openid profile email accounting.transactions accounting.contacts accounting.settings'Do not create a separate config/initializers/omniauth.rb file when using Devise.
Add to your routes in config/routes.rb:
devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }Make your User model omniauthable in app/models/user.rb:
devise :omniauthable, omniauth_providers: [:xero_oauth2_modern]Create the callbacks controller at app/controllers/users/omniauth_callbacks_controller.rb:
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def xero_oauth2_modern
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Xero')
sign_in_and_redirect @user, event: :authentication
else
session['devise.xero_data'] = request.env['omniauth.auth'].except('extra')
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
end
end
def failure
redirect_to root_path, alert: "Authentication failed: #{failure_message}"
end
endFor your views, create a login button:
<%= button_to "Sign in with Xero", user_xero_oauth2_modern_omniauth_authorize_path, method: :post %>Configuration Options
| Option | Default | Description |
|---|---|---|
scope |
openid profile email accounting.transactions accounting.contacts accounting.settings |
OAuth scopes to request. |
redirect_uri |
Auto-generated | Custom redirect URI (optional). |
Example with all options:
provider :xero_oauth2_modern,
ENV['XERO_CLIENT_ID'],
ENV['XERO_CLIENT_SECRET'],
scope: 'openid profile email accounting.transactions accounting.contacts accounting.settings',
redirect_uri: 'https://myapp.com/auth/xero_oauth2_modern/callback'Auth Hash
Here's an example of the authentication hash available in the callback via request.env['omniauth.auth']:
{
"provider" => "xero_oauth2_modern",
"uid" => "tenant-uuid-123", # First connected tenant's tenantId
"info" => {
"email" => "user@example.com",
"first_name" => "John",
"last_name" => "Doe",
"name" => "John Doe",
"tenant_id" => "tenant-uuid-123",
"tenant_name" => "My Company"
},
"credentials" => {
"token" => "ACCESS_TOKEN",
"refresh_token" => "REFRESH_TOKEN",
"expires_at" => 1704067200,
"expires" => true
},
"extra" => {
"tenant_id" => "tenant-uuid-123",
"tenants" => [
{
"id" => "connection-uuid",
"tenantId" => "tenant-uuid-123",
"tenantType" => "ORGANISATION",
"tenantName" => "My Company",
"createdDateUtc" => "2026-01-01T00:00:00.0000000",
"updatedDateUtc" => "2026-01-01T00:00:00.0000000"
}
],
"raw_info" => {
"sub" => "user-uuid",
"email" => "user@example.com",
"given_name" => "John",
"family_name" => "Doe",
"xero_userid" => "user-uuid"
}
}
}Multi-Tenant Support
Xero supports connecting to multiple organizations (tenants). After authentication, the strategy automatically fetches all connected tenants via the /connections endpoint.
- The
uidis set to the first tenant'stenantId(organization ID) - All connected tenants are available in
extra.tenants - Each tenant includes
tenantId,tenantName, andtenantType
To make API calls to a specific tenant, include the Xero-Tenant-Id header:
response = Faraday.get("https://api.xero.com/api.xro/2.0/Invoices") do |req|
req.headers['Authorization'] = "Bearer #{access_token}"
req.headers['Xero-Tenant-Id'] = tenant_id
req.headers['Accept'] = 'application/json'
endXero OAuth 2.0 Specifics
This gem handles several Xero-specific OAuth 2.0 behaviors:
-
Split Endpoints: Xero uses separate domains for authorization (
login.xero.com), token exchange (identity.xero.com), and API calls (api.xero.com). -
Multi-Tenant: After authentication, call the
/connectionsendpoint to get the list of authorized tenants. -
Basic Auth for Tokens: Token exchange and refresh use HTTP Basic Auth with base64-encoded
client_id:client_secret. -
Short-Lived Access Tokens: Access tokens expire in 30 minutes.
-
Refresh Token Expiry: Refresh tokens expire in 60 days.
-
Rate Limits: 60 calls/minute per tenant, 5,000 calls/day per tenant.
Token Management
Xero access tokens expire after 30 minutes. Refresh tokens are valid for 60 days.
Store tokens securely and implement refresh logic:
def self.from_omniauth(auth)
user = where(provider: auth.provider, uid: auth.uid).first_or_create do |u|
u.email = auth.info.email
u.password = Devise.friendly_token[0, 20]
end
# Update tokens on each login
user.update(
xero_access_token: auth.credentials.token,
xero_refresh_token: auth.credentials.refresh_token,
xero_token_expires_at: Time.at(auth.credentials.expires_at),
xero_tenant_id: auth.info.tenant_id,
xero_tenant_name: auth.info.tenant_name
)
user
endToken Refresh
This gem includes a TokenClient class to easily refresh tokens in your Rails app.
Basic Usage
# Create a client instance
client = OmniAuth::XeroOauth2Modern::TokenClient.new(
client_id: ENV['XERO_CLIENT_ID'],
client_secret: ENV['XERO_CLIENT_SECRET']
)
# Refresh an expired token
result = client.refresh_token(account.xero_refresh_token)
if result.success?
account.update!(
xero_access_token: result.access_token,
xero_refresh_token: result.refresh_token,
xero_token_expires_at: Time.at(result.expires_at)
)
else
Rails.logger.error "Token refresh failed: #{result.error}"
endCheck Token Expiration
# Check if token is expired (with 5-minute buffer by default)
client.token_expired?(account.xero_token_expires_at)
# Custom buffer (e.g., refresh 10 minutes before expiry)
client.token_expired?(account.xero_token_expires_at, buffer_seconds: 600)Rails Service Example
# app/services/xero_api_service.rb
class XeroApiService
def initialize(account)
@account = account
@client = OmniAuth::XeroOauth2Modern::TokenClient.new(
client_id: ENV['XERO_CLIENT_ID'],
client_secret: ENV['XERO_CLIENT_SECRET']
)
end
def with_valid_token
refresh_if_expired!
yield @account.xero_access_token
end
private
def refresh_if_expired!
return unless @client.token_expired?(@account.xero_token_expires_at)
result = @client.refresh_token(@account.xero_refresh_token)
raise "Token refresh failed: #{result.error}" unless result.success?
@account.update!(
xero_access_token: result.access_token,
xero_refresh_token: result.refresh_token,
xero_token_expires_at: Time.at(result.expires_at)
)
end
end
# Usage
XeroApiService.new(current_account).with_valid_token do |token|
# Make API calls with valid token
response = Faraday.get("https://api.xero.com/api.xro/2.0/Invoices") do |req|
req.headers['Authorization'] = "Bearer #{token}"
req.headers['Xero-Tenant-Id'] = current_account.xero_tenant_id
req.headers['Accept'] = 'application/json'
end
endTokenResult Object
The refresh_token method returns a TokenResult object with:
| Method | Description |
|---|---|
success? |
Returns true if refresh succeeded |
failure? |
Returns true if refresh failed |
access_token |
The new access token |
refresh_token |
The new refresh token |
expires_at |
Unix timestamp when token expires |
expires_in |
Seconds until token expires |
error |
Error message if failed |
raw_response |
Full response hash from Xero |
Scopes
Common Xero scopes:
| Scope | Description |
|---|---|
openid |
OpenID Connect (required for userinfo) |
profile |
User profile information |
email |
User email address |
accounting.transactions |
Invoices, bank transactions, payments, credit notes |
accounting.transactions.read |
Read-only access to transactions |
accounting.contacts |
Contacts and contact groups |
accounting.contacts.read |
Read-only access to contacts |
accounting.settings |
Accounts, tax rates, tracking categories |
accounting.settings.read |
Read-only access to settings |
accounting.reports.read |
Financial reports (read-only) |
accounting.journals.read |
Journals (read-only) |
accounting.attachments |
File attachments |
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/dan1d/omniauth-xero-oauth2-modern.
- Fork it
- Create your feature branch (
git checkout -b feature/my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin feature/my-new-feature) - Create a new Pull Request
Development
After checking out the repo, run bundle install to install dependencies. Then, run bundle exec rspec to run the tests.
bundle install
bundle exec rspec
bundle exec rubocopLicense
The gem is available as open source under the terms of the MIT License.