No release in over 3 years
An OmniAuth strategy for authenticating with Xero using OAuth 2.0. Compatible with OmniAuth 2.0+ with multi-tenant support, OpenID Connect userinfo fetching, and token refresh capabilities.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 2.0
~> 2.1
~> 13.0
~> 3.12
= 1.84.0
~> 2.31.0
~> 0.22
~> 3.18

Runtime

>= 1.0, < 3.0
~> 2.0
 Project Readme

OmniAuth Xero OAuth2 Strategy

Gem Version CI

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 install

Or install it yourself:

$ gem install omniauth-xero-oauth2-modern

Xero Developer Setup

  1. Go to the Xero Developer Portal
  2. Create a new app or select an existing one
  3. Note your Client ID and Client Secret
  4. 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
end

For 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 uid is set to the first tenant's tenantId (organization ID)
  • All connected tenants are available in extra.tenants
  • Each tenant includes tenantId, tenantName, and tenantType

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'
end

Xero OAuth 2.0 Specifics

This gem handles several Xero-specific OAuth 2.0 behaviors:

  1. Split Endpoints: Xero uses separate domains for authorization (login.xero.com), token exchange (identity.xero.com), and API calls (api.xero.com).

  2. Multi-Tenant: After authentication, call the /connections endpoint to get the list of authorized tenants.

  3. Basic Auth for Tokens: Token exchange and refresh use HTTP Basic Auth with base64-encoded client_id:client_secret.

  4. Short-Lived Access Tokens: Access tokens expire in 30 minutes.

  5. Refresh Token Expiry: Refresh tokens expire in 60 days.

  6. 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
end

Token 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}"
end

Check 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
end

TokenResult 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.

  1. Fork it
  2. Create your feature branch (git checkout -b feature/my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin feature/my-new-feature)
  5. 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 rubocop

License

The gem is available as open source under the terms of the MIT License.