Devise with OmniAuth for Single and Multiple Models – Rails 5

In this post we’ll describe on how to use OmniAuth in combination with Rails and Devise to support authentication of existing and new users without asking for email/password combinations.

Devise Authentication

It would be best to demonstrate the concept on a live application. So let us start off with the new application:


    #execute from terminal
    rails new oauth_example  
    cd oauth_example  
    rails g scaffold city name:string postcode:string

We need to add devise:


    #Gemfile.rb
    gem 'devise'

Then we’ll make sure we have all the required dependencies and that our User model’s all set up:


    #execute from terminal
    bundle install  
    rails generate devise:install  
    rails generate devise user  
    rake db:migrate

All our controller methods should require authentication (for this example of ours)


    #app/controllers/application_controller.rb
    before_action :authenticate_user!

We’re gonna set the default route to be the city index page


    #config/routes.rb
    root 'cities#index'

Now you can test out devise over at http://localhost:3000/

Single Model

In cases when we only need to support one model for OmniAuth (User for example) then it’s quite simple and it all works reasonably well out of the box. We just need to add the appropriate OAuth strategy (Facebook in this particular case) and everything will magically work.

Omniauthable


    #Gemfile.rb
    gem 'omniauth-oauth2', '1.3.1'  
    gem 'omniauth-facebook'

We’ll initialize Devise – make sure you open up a Facebook app and then add the correct


 APP_ID

and


 SECRET_ID

to the initializer. (the Facebook app needs to be created over here: http://developers.facebook.com/apps)

For the Facebook app to work you’ll need to add a valid Oauth redirect URL under Product/Facebook Login/Settings


    http://localhost:3000/users/auth/facebook/callback

In order to do this you will need to add “Settings/Basic” App Domains with the value “localhost”


    #config/initializers/devise.rb
    config.omniauth :facebook, ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_SECRET_ID'],  
                    scope: 'email',
                    info_fields: 'email'

Then we need to make our model aware of this:


    #app/models/user.rb
    devise :omniauthable, omniauth_providers: [:facebook]

    #execute from terminal
    bundle install

We then add this to the bottom of the cities index html.erb page:


    #app/views/cities/index.html.erb
    <% if user_signed_in? %>  
        <%= link_to('Logout', destroy_user_session_path, :method => :delete) %>        
    <% else %>  
        <%= link_to('Login', new_user_session_path)  %>  
    <% end %>

Change routes for users to:


    #config/routes.rb
    devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }

Generate controller action


    #execute from terminal
        rails g controller users/omniauth_callbacks

    #app/controllers/users/omniauth_callbacks_controller.rb
    class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController  
        def facebook
            # You need to implement the method below in your model (e.g. app/models/user.rb)
            @user = User.from_omniauth(request.env["omniauth.auth"])

            if @user.persisted?
                sign_in_and_redirect @user, :event =&gt; :authentication #this will throw if @user is not activated
                set_flash_message(:notice, :success, :kind =&gt; "Facebook") if is_navigational_format?
            else
                session["devise.facebook_data"] = request.env["omniauth.auth"]
                redirect_to new_user_registration_url
            end
        end

        def failure
            redirect_to root_path
        end
    end

and finally we add this to our app/models/user.rb


    #app/models/user.rb
        def self.new_with_session(params, session)
            super.tap do |user|
                if data = session["devise.facebook_data"] &amp;&amp; session["devise.facebook_data"]["extra"]["raw_info"]
                    user.email = data["email"] if user.email.blank?
                end
            end
        end

        def self.from_omniauth(auth)
            user = User.find_by('email = ?', auth['info']['email'])
            if user.blank?
                user = User.new(
                  {
                  provider: auth.provider,
                  uid: auth.uid,
                  email: auth.info.email,
                  password: Devise.friendly_token[0,20]
                  }
                )
                user.save!
            end
            user
        end

I’m adding the Provider and UID to the User model using the next migration. This is just a poor man’s version as I am cheating a little by using only one user model. For multiple Auth providers (LinkedIn, Google+ …) we should keep our authorizations somewhere else so we would require one more table. This is described in detail over here: http://stackoverflow.com/a/22186061/185374


    #execute from terminal
    rails generate migration add_oauth_fields_to_users provider:string uid:string  
    rake db:migrate

This should be enough to have a working OAuth set up.
All of the code is available here: https://github.com/kodius/oauth-example-single-model

Multiple Models

When we need to support multiple models for OmniAuth, the whole thing gets a tad more complicated. The default “omniauthable” Devise way of doing things is not actually supported for multiple models (https://github.com/plataformatec/devise/issues/1047). Instead, what we need to do is use OAuth as a middleware and we need to write routes by hand to make it work.

Using middleware

This requires us to remove the :omniauthable argument from our User model(app/models/user.rb)

devise :omniauthable, omniauth_providers: [:facebook]

Remove the configuration setup from devise.rb


    #config/initializers/devise.rb
    #config.omniauth :facebook, ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_SECRET_ID'],
    #                 scope: 'email',
    #                 info_fields: 'email'

Create a new file called oauth.rb in the /initializers folder


    #config/initializers/oauth.rb
    Rails.application.config.middleware.use OmniAuth::Builder do  
        provider :facebook,
                ENV['FACEBOOK_APP_ID'],
                ENV['FACEBOOK_SECRET_ID'],
                scope: 'email',
                info_fields: 'email',
                auth_type: 'rerequest'

        configure do |config|
          config.path_prefix = '/users/auth'
        end
    end

Remove the Devise routes from config/routes.rb

devise_for :users, controllers: { omniauth_callbacks: ‘users/omniauth_callbacks’ }

Now it should look like this:


    #config/routes.rb
    devise_for :users

Add a manual route:


    #config/routes.rb
    devise_scope :user do  
        get "/users/auth/facebook/callback" => "users/omniauth_callbacks#facebook"
    end

We need to add a “Facebook” button on our Sign Up page. Let’s generate the Devise Views so we can modify them:


    #execute in terminal
    rails generate devise:views

Put Facebook the button just before last line

<%= render “devise/shared/links” %>


    #app/views/devise/sessions/new.html.erb
    #put these three lines
    <%= link_to('/users/auth/facebook', {:class => "btn btn-primary"}) do %>
        Facebook OAuth
    <%- end %>  
    <br/>

    #before this line
    <%= render "devise/shared/links" %>

One more thing we need to do is we have to gracefully handle failure. We’ll do this by adding the on_failure block


    #config/initializers/oauth.rb
    on_failure do |env|  
        #we need to setup env
        if env['omniauth.params'].present
            env["devise.mapping"] = Devise.mappings[:user]
        end
        Devise::OmniauthCallbacksController.action(:failure).call(env)
    end

This leaves us with a setup that can be easily extended to multiple models merely by adding some routes and handlers. Since we’re relying on middleware here and this means we have full control over the entire configuration and making everything work ought to be fairly straightforward. We simply need to do the same thing we’ve already done thus far and replace “user” with the respective model that we’re adding (e.g. “employer_user” or whatever it is that we’re using).

The full working example with middleware OmniAuth is available over here:https://github.com/kodius/oauth-example-multiple-models

Testing

When testing this, we can’t really use Facebook so what we do instead is we mock Facebook’s response.


    module OmniauthMacros  
        def mock_auth_hash
            OmniAuth.config.mock_auth[:default] = OmniAuth::AuthHash.new(
                'provider' => 'facebook',
                'uid' => '123545',
                'info' => {
                    'name' => 'mockuser',
                    'image' => 'mock_user_thumbnail_url',
                    'first_name' => 'john',
                    'last_name' => 'doe',
                    'email' => 'john@doe.com',
                    'urls' => {
                        'public_profile' => 'http://test.test/public_profile'
                  }
                },
                'credentials' => {
                'token' => 'mock_token',
                'secret' => 'mock_secret'
                },
                'extra' => {
                'raw_info' => '{"json":"data"}'
                }
            )
        end
    end

In RSpec we do this before running our tests:


    RSpec.feature 'Facebook login management', type: :feature do  
        before(:each) do
            OmniAuth.config.test_mode = true
            mock_auth_hash
        end
        scenario 'Should login over facebokok do
            visit '/users/sign_up'
            click_on 'Facebook OAuth'
            #.....
        end
    end

This should be everything you need to successfully use OAuth in your Rails application.