How to Sign in with Twitter using Devise, Omniauth, and Ruby on Rails

Welcome to another installment in my Let’s build with Ruby on Rails: Extending Devise series. This tutorial teaches you how to sign in with Twitter using Devise, Omniauth, and Ruby on Rails.

Getting started

If you would like to use my Kickoff Tailwind template you can download it here

Download the source code

Create a new app

$ rails new devise_sign_in_with_twitter -m kickoff_tailwind/template.rb

The command above assumes you have downloaded the kickoff_tailwind repo and have the folder name kickoff_tailwind in the same directory as the app you are creating.

We’ll reference these gems in this guide. You may already have Devise installed if you used my kickoff_tailwind template.

Gemfile:

gem 'devise'
gem 'omniauth'
gem 'omniauth-twitter'

Migrations

Generate a new migration for the user model. It will have two fields called provider and uid. Both are strings.

rails g migration AddOmniauthToUsers provider:string uid:string
rails db:migrate

== 20191005160517 AddOmniauthToUsers: migrating ===============================
-- add_column(:users, :provider, :string)
   -> 0.0028s
-- add_column(:users, :uid, :string)
   -> 0.0007s
== 20191005160517 AddOmniauthToUsers: migrated (0.0037s) ======================

Devise configuration

We need to create a new application on https://developer.twitter.com. This process isn’t 100% straight-forward but is possible. There’s an application step you will need to go through.

To get the API keys shown below there is a process for Twitter. You need to go through an application phase but once done there should be a Keys and Tokens tab within your Twitter developer app.

Once you have your keys you can add them to your Application Credentials. Run the following:

$ rails credentials:edit

If you get some feedback about what editor you would like to use, you’ll need to re-run the script with a editor of your choice. I prefer Visual Studio code.

No $EDITOR to open file in. Assign one like this:

EDITOR="mate --wait" bin/rails credentials:edit

For editors that fork and exit immediately, it's important to pass a wait flag,
otherwise the credentials will be saved immediately with no chance to edit.

EDITOR="code --wait" bin/rails credentials:edit

We need to add a new configuration to our devise configuration next. Using the credentials we just added we pass those through a special ominauth twitter configuration.

# app/config/initializers/devise.rb

Devise.setup do |config|
  ...
  config.omniauth :twitter, Rails.application.credentials.fetch((:twitter_api_public), Rails.application.credentials.fetch(:twitter_api_secret)
  ...
end

Make User model omniauthable

We need to modify our existing Devise install to account for the new omniauth logic. Doing so happens in the User model. You can provide multiple providers depending on your app. For the sake of brevity, I’m sticking to Twitter for now.

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: %i[twitter] # add these two! 
end

With this added, Devise will create methods for us for each provider we pass. It does not create the _url methods like other link helpers typically do.

user_{provider}_omniauth_authorize_path
user_{provider}_omniauth_callback_path

OmniAuth routing

With the model in place, our routing needs some attention. We need to tap into the Devise omniauth callbacks controller to do some logic there.

Change the routes file to the following:

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do
  authenticate :user, lambda { |u| u.admin? } do
    mount Sidekiq::Web => '/sidekiq'
  end


  devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
  root to: 'home#index'

end

_Note: this guide assumes you are using my kickoff_tailwind template. Your routes file may differ from the above if not.

OmniAuth Controllers

We are passing the path in which the OmniAuth Callbacks controller exists in our routes.rb file. Let’s create that folder and file next inside app/controllers/users

# app/controllers/users/omniauth_callbacks_controller
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
    def twitter
    # 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: :authentication #this will throw if @user is not activated
      set_flash_message(:notice, :success, kind: "Twitter") if is_navigational_format?
    else
      session["devise.twitter_data"] = request.env["omniauth.auth"].except("extra")
      redirect_to new_user_registration_url
    end
  end

  def failure
    redirect_to root_path
  end

end

More logic in the user model

There’s a method called from_omniauth we haven’t defined yet but referenced in the controller. We’ll add this method to the user model:

# app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: %i[twitter]


  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
    user.email = auth.info.email
    user.password = Devise.friendly_token[0, 20]
    user.name = auth.info.name # assuming the user model has a name
    user.username = auth.info.nickname # assuming the user model has a username
    #user.image = auth.info.image # assuming the user model has an image
    # If you are using confirmable and the provider(s) you use validate emails,
    # uncomment the line below to skip the confirmation emails.
    # user.skip_confirmation!
    end
  end

end

The method above will try to find any existing user or create on if the user doesn’t exist yet. While doing so we pass in information such as email, name, and an automatic password made possible by Devise. Here you can also pass and image if you have an avatar of some type on your user model already. For this guide, I will skip that step.

Adding a “Sign in with Twitter” link

If you’re using my kickoff_tailwind application template you should already have a view for the login screen. We’ll make a small change to add a new “Sign in with Twitter” button. Devise ads this dynamically for us but I wanted a more refined style.

<!-- app/views/devise/sessions/new.html.erb -->
<% content_for :devise_form do %>
  <h2 class="heading text-4xl font-bold pt-4 mb-8">Log in</h2>
  <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>

  <div class="mb-6">
    <%= f.label :email, class:"label" %>
    <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "input" %>
  </div>

  <div class="mb-6">
    <%= f.label :password, class:"label" %>
    <%= f.password_field :password, autocomplete: "current-password", class: "input" %>
  </div>

  <div class="mb-6 flex">
    <% if devise_mapping.rememberable? -%>
      <%= f.check_box :remember_me, class:"mr-2 leading-tight" %>
      <%= f.label :remember_me, class:"block text-grey-darker" %>
    <% end -%>
  </div>

  <div class="mb-6">
    <%= f.submit "Log in", class: "btn btn-default" %>
  </div>

  <div class="mb-6">
    <%= link_to "Sign in with Twitter", user_twitter_omniauth_authorize_path, class: "btn bg-blue-500 text-white w-full block text-center" %>
  </div>

<% end %>

<hr class="border mt-6" />

<%= render "devise/shared/links" %>
<% end %>

<%= render "devise/shared/form_wrap" %>

All that has changed here is the addition of this line:

 <div class="mb-6">
    <%= link_to "Sign in with Twitter", user_twitter_omniauth_authorize_path, class: "btn bg-blue-500 text-white w-full block text-center" %>
  </div>

And we are left with some quick design like so:

https://i0.wp.com/i.imgur.com/uk9PxET.png?ssl=1

Clicking this should hopefully get us what we are after. But instead I get a OAuth::Unauthorized message. After some googling I found out that we need to have a series of callback urls defined in your Twitter developer account.

https://i1.wp.com/i.imgur.com/MLrV86f.png?ssl=1

The URL schemes below worked for me.

https://i0.wp.com/i.imgur.com/PI16v6z.png?ssl=1

Now we can try clicking “Sign in with Twitter” again.

https://i1.wp.com/i.imgur.com/R4QJ9x4.png?ssl=1

Success! Now we can authorize the app and hopefully be redirected back to our demo Rails application.

But now I get a new error that has to do with our session size. This is a warning when your Cookie size is over the 4K limit. To get around this we can harness a gem called ‘activerecord-session_store’

https://i2.wp.com/i.imgur.com/ZvwzRPW.png?ssl=1

Add that to your gemfile

gem 'activerecord-session_store'

and run bundle install

Then we need a new migration:

rails generate active_record:session_migration
rake db:migrate


Running via Spring preloader in process 6189
      create  db/migrate/20191005165228_add_sessions_table.rb
Running via Spring preloader in process 6203
== 20191005165228 AddSessionsTable: migrating =================================
-- create_table(:sessions)
   -> 0.0022s
-- add_index(:sessions, :session_id, {:unique=>true})
   -> 0.0015s
-- add_index(:sessions, :updated_at)
   -> 0.0018s
== 20191005165228 AddSessionsTable: migrated (0.0056s) ========================

Finally we need to add a new session_store.rb file to our config/initializers folder:

# config/initializers/session_store.rb
Rails.application.config.session_store :active_record_store, :key => '_my_app_session'

Inside you can copy and paste this one-liner. Feel free to name your key something more descriptive.

Be sure to restart your server if you haven’t.

Try logging in again. This time we don’t get errors! But what we do get it redirected to the Sign Up view.

This is issue most likely do to the current null: false constraint on the users table and email column:

# db/schema.rb

create_table "users", force: :cascade do |t|
    t.string "email", default: "", null: false
    ...

We need to swap that to true. Let’s do so with a new migration:

rails g migration changeEmailOnUsers

Inside that file add the following:

class ChangeEmailOnUsers < ActiveRecord::Migration[6.0]
  def change
    change_column :users, :email, :string, null: true
  end
end

Then run:

rails db:migrate

Restart your server and try to “Sign in with Twitter” again. Your schema.rb file should remove the null: method entirely if all goes correctly.

A gotcha (with Twitter)

If you try logging in currently you will be redirected to localhost:3000/users/sign_up. This is because there’s some behind the scenes validation going on with the Devise gem. Sadly, no errors or logs tell us what has happened.

I searched for some answers here and found out that. Twitter doesn’t exactly require an email to create an account via API. Our Devise configuration requires an e-mail to be present. To override this (for now) you can add this method in the user model.

# app/models/user.rb
class User < ApplicationRecord
  ...

  def email_required?
     false
  end
end

This tells Devise to make emails not required for authentication.

Logging in now should work

https://i1.wp.com/i.imgur.com/oWxgCur.png?ssl=1

Sweeet! It works.

Wrapping up

My implementation here is very rigid but gets the job done. If you want to allow users to sign in with multiple providers it might make sense to automate some of the methods in the controller and model layer. You could even go pretty deep with configuration layer automation so a user enters their API keys and preferred providers and the rest is automatic. We did something similar on Jumpstart

Coming up I’ll introduce new providers and extend the Devise gem even more. Stay tuned!

Shameless plug time

I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you’re a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. Download your copy today!!

Follow @hello_rails and myself @justalever on Twitter.