Nick Hammond

Software Developer, Cyclist, & Traveler.

Authentication with 37Signals' OAuth and omniauth-37signals for your Rails application

In rails

First you'll need to register your application so that you can obtain your 37Signals client credentials. I recommend setting the redirect URI to http://127.0.0.1/users/auth/37signals/callback so that you can test it locally if you need to. If you're using pow.cx you can specify that symlink as the default and easily test your OAuth integration. Even better if you're using the powder gem you can just run "powder default" from the app root and it'll link that up as the default app which will then respond at 127.0.0.1.

Next you'll need the omniauth-37signals gem. I'm using Devise with Omniauth support so I'm going to assume you're doing something similar. Your Gemfile should have the following in regards to devise/omniauth, upgrade versions as needed:


gem "devise", ">= 2.2.3"
gem "omniauth-37signals", "~> 1.0.5"

Run bundle install to install the new gems and then run "rails generate devise:install" to create the default devise.rb initializer file. Then configure the omniauth provider for 37Signals:


Devise.setup do |config|
  config.omniauth '37signals', 'id', 'secret'
end

You'll probably want to setup a model to interact with devise, go ahead and run that next "rails generate devise User email name". This will generate the User model as well as bunch of other relevant files and methods for interacting with your user. Typically you'll want to authenticate with multiple OAuth providers, the easiest way to do this is to create a Authorization model which will store the relevant OAuth provider's details:


rails generate model Authorization provider:string uid:string user:references oauth_token:text oauth_refresh_token:text oath_expires_at:datetime

Make sure you set the oauthtoken and oauthrefreshtoken as text columns, they use pretty long keys and they usually get truncated on accident if you use a varchar. OAuth tokens from 37Signals also expire after a given amount of time, they provide this date in the returned authentication details so we'd like to store that for future reference. The oauthrefreshtoken is used to obtain a new oauthtoken without needing to prompt the user to go through the OAuth process again, if we wanted to we could just run a background job to refresh tokens that are about to expire.

After you've done that make sure to migrate and then setup your models with the following settings:


class User < ActiveRecord::Base
  attr_accessible :name, 
    :email, 
    :password, 
    :password_confirmation, 
    :remember_me
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable, 
         :omniauthable
  has_many :authorizations
end


class Authorization < ActiveRecord::Base
  belongs_to :user
  attr_accessible :provider, 
    :uid,
    :oauth_token, 
    :oauth_refresh_token, 
    :oauth_expires_at

  validates :provider, :uid, :presence => true
end

Next you'll need to make sure you have the proper routes setup.


  devise_scope :user do
  # Normally the provider is mapped back to an action such as :facebook mapping back to the "facebook" action within
  # the controller. Since we can't name an action "37signals" we have to map it to a method name that is allowed.
    get '/users/auth/37signals/callback' => 'users/omniauth_callbacks#thirty_seven_signals'
  end
  devise_for :users, :controllers => { :registrations => 'registrations', :omniauth_callbacks => "users/omniauth_callbacks" }

Now you'll need to create your omniauth callback controller:


class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController

  def thirty_seven_signals
    oauthorize
  end

  def passthru
    render :file => "#{Rails.root}/public/404.html", :status => 404, :layout => false
  end

private

  def oauthorize
    @user = find_for_ouath(env["omniauth.auth"], current_user)
    if @user
      flash[:notice] = I18n.t "devise.omniauth_callbacks.success", :kind => kind
      session["devise.#{kind.downcase}_data"] = env["omniauth.auth"]
      sign_in_and_redirect @user, :event => :authentication
    end    
  end

  def find_for_ouath(access_token, resource=nil)
    if resource
      # Means our user is signed in. Add the authorization to the user
      resource.add_provider(access_token)
    else
      auth = Authorization.find_or_create(access_token)
      auth.user
    end
  end

end

Now we'll need to add in those new methods that we just used in User and Authorization.


  def self.find_or_create(auth_hash)
    unless auth = find_by_provider_and_uid(auth_hash["provider"], auth_hash["uid"])
      user = User.find_or_create_by_email(auth_hash["info"]["email"])
      user.update_attribute(:name, auth_hash["info"]["name"])

      auth = user.authorizations.create(
        :provider => auth_hash["provider"], 
        :uid => auth_hash["uid"],
        :oauth_token => auth_hash["credentials"]["token"],
        :oauth_refresh_token => auth_hash["credentials"]["refresh_token"],
        :oauth_expires_at => auth_hash["extra"]["raw_info"]["expires_at"]
      )
    end
    auth
  end


  def add_provider(auth_hash)
    # Check if the provider already exists, so we don't add it twice
    unless authorizations.find_by_provider_and_uid(auth_hash["provider"], auth_hash["uid"])
      authorizations.create(
        :provider => auth_hash["provider"], 
        :uid => auth_hash["uid"],
        :oauth_token => auth_hash["credentials"]["token"],
        :oauth_refresh_token => auth_hash["credentials"]["refresh_token"],
        :oauth_expires_at => auth_hash["extra"]["raw_info"]["expires_at"]
      )
    end
    self
  end

This is what env["omniauth.auth"] looks like when a user comes back from connecting your application, just for reference:


{
  "provider": "37signals",
  "uid": 555,
  "info": {
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Smith",
    "name": "John Smith"
  },
  "credentials": {
    "token": "...",
    "refresh_token": "...",
    "expires_at": 1362252189,
    "expires": true
  },
  "extra": {
    "accounts": [{
      "name": "Basecamp Project 1",
      "href": "https://project1.basecamphq.com",
      "id": 123,
      "product": "basecamp"
    }, {
      "name": "Campfire account",
      "href": "https://campfire1.campfirenow.com",
      "id": 456,
      "product": "campfire"
    }]
  },
  "raw_info": {
    "accounts": [{
      "name": "Basecamp Project 1",
      "href": "https://project1.basecamphq.com",
      "id": 123,
      "product": "basecamp"
    }, { 
      "name": "Campfire account",
      "href": "https://campfire1.campfirenow.com",
      "id": 456,
      "product": "campfire"
    }]
    },
  "expires_at": "2013-03-02T19:23:09Z",
  "identity": {
    "id": 555,
    "last_name": "Smith",
    "email_address": "user@example.com",
    "first_name": "John"
  }
}

Once you've setup all of that the last thing to do is just provide a link to get them connected to the OAuth dialog box by using "useromniauthauthorize_path('37signals')".

If you want to test sign in via OAuth and the 37Signals provider then you can use the add_mock method. If you place this in features/support/omniauth.rb it'll load and take over whenever you hit the OAuth URLs to properly return the specified hash.


OmniAuth.config.test_mode = true
# mock_auth expects a symbol so you have to provide it in this manner for it to properly reference it when mocking the OAuth calls. I've only added the items that I needed for my application, there's more that's returned as you'll notice in the example hash above.
OmniAuth.config.mock_auth[:"37signals"] = {
    "provider"=> "37signals",
    "uid"=> "556595",
    "info"=> {
      "email"=> "test@xxxx.com", 
      "name"=> "Test User"
    },
    "credentials" => {
      "token" => "secret-token",
      "refresh_token" => "refresh-token",
      "expires_at" => 1362252189
    },
    "extra" => {
      "accounts" => [{
        "name" => "Campfire account",
        "href" => "https://example.basecamphq.com",
        "id" => 1399584,
        "product" => "campfire"
      }],
      "raw_info" => {"expires_at" => 20.days.from_now}
    }
}

P.S. We should keep in touch, subscribe below.