My Blog

A blog built with Bootstrap and Actiontext in Rails.

Topics

Oauth2 in Rails 7 - How to Create "Login With X" Functionality

Greetings.  I am currently working on The Odin Project, and I'm doing the final rails project, which is a "facebook clone".  It doesn't have to look like facebook, but it needs the functions of posts, likes, comments, friendships, notifications, etc.  One of the stipulations is that it needs to have an Oauth "log in with facebook" button option for the sign in.  I thought "great, this should be straightforward as I've seen that devise has an Oauth feature.  I'll just follow their instructions".  That turned out to be very far from the truth.  The goal of this blog post is to document what did work so I can remember it later on as well as to share it with others who might be confronted with the same task themselves. 

To the unfamiliar, Devise is a very popular gem that handles pretty much every aspect of app security, signup, login, password recovery, etc, and provides a nice set of views and controllers to work with.  Your biggest task, generally, is just styling the forms you want to use.  It has nearly 150 million downloads, so it's pretty widely used.  For this task I started off following their directions here.  They do mention that Oauth2 requires that requests be sent as POST requests and that GET won't work.  They offer some strategies for making that happen, however Rails 7 uses Turbo and Turbo wants to pretty much do everything via  XHR GET request.  Turbo allows for updating any part of the page without having to reload the whole thing.  This is great and makes for snappy delivery, but it can cause some quirks.  The Devise instructions mention using a button, or adding "method: :post" to the link, but in both cases they were still showing "Started GET..." in the Rails server.  Even trying to disable turbo with data-turbo="false" didn't work.  You can get Turbo to do a POST request with 

data: { turbo_method: :post }
lang-ruby
However, this still resulted in a nasty looking CORS error in the dev console.  After a bit of googling, I learned that it's something to do with Turbo using XHR.  Ultimately, what I found out is that you can specify that you want the request to go as HTML.  Here is the final code that worked:

<div class="github-login" data-turbo="false">      
  <%= button_to "Sign in with Github", member_github_omniauth_authorize_path,
  format: :html, method: :post, class: "button is-link" %>    
</div>lang-ruby

So, that covered one of the sticking points in getting Oauth2 to work.  However, that was not the only issue that I ran into.  To begin with, I was not able to register at all for Meta's developer portal.  As of the time of this writing it is still broken.  I've detailed that in this blog post.  In order to get the system to work, you have to register as a developer and create an app on their website.  As I couldn't use theirs, I used githubs Oauth feature to start.  I found some pretty decent instructions for how to sign up and get github working here.  I won't re-hash all the steps in this article.  I'm just going to detail what I had to change.  After following those steps and clicking on my "sign in with Github" button, I was redirected to the signup form in my app.  I could see the POST request happen in the server, but it wasn't signing me in and there were no error messages.  I eventually added this line of code to the OmniauthsCallbacksController:

      puts member.errors.full_messages
lang-ruby
And then I was able to see the DB ROLLBACK message in the server with the error that email was already in use.  I had previously manually registered with my app and it was causing a collision because the email field in my database has "unique: true" attribute.  It finally hit me that the behavior was all wrong.  I wanted it to sign me in, not sign me up with a new account. So, it was back to google to see what I could find.  I found an excellent stack overflow answer.  That gave some pretty good details on what I should do.  It was a little old, so it needed some adjustments, but it got me most of the way there.  

A note for the code that follows: I am using the model "member" in place of "user" because I'm intending to put this app on Heroku when completed, and I want to share a database with this app, which already uses "user".  You can't have two tables with the same name in a database.  With devise, you can call your "users" anything you want.  Devise isn't picky about it as long as you stay consistent throughout. So, if you use this code, you'll want to adjust for that.  

To start with, these are the gems used to accomplish the login functionality:

gem 'dotenv-rails'
gem 'devise' 
gem 'omniauth-facebook', '~> 9.0'
gem 'omniauth-rails_csrf_protection' 
gem 'omniauth-github', '~> 2.0', '>= 2.0.1' 
gem 'omniauth-google-oauth2' 
lang-ruby 

Once you get signed up with github, and in my case, google Oauth, you'll want to put those credentials in a .env file in your root folder. The dotenv gem allows for accessing them. Add .env to your gitignore file so that you aren't checking these credentials into version control where anyone could get them. Don't worry, what I've posted below are not the actual credentials. The just look similar.

GITHUB_CLIENT_ID=Iv1.7fd3298d016109b6
GITHUB_CLIENT_SECRET=86266384c7634b15746216dc7a765a9c4026025

GOOGLE_OAUTH2_CLIENT_ID=538637935741-eus398tnf75g4n9vcjb435l621f5pnn8.apps.googleusercontent.com
GOOGLE_OAUTH2_CLIENT_SECRET=GODSPX-UPz4W3NBj9XTOsLngDc12gYlI0vh 
lang-ruby


In config/initializers/devise.rb:

  config.omniauth :github,                  
        ENV["GITHUB_CLIENT_ID"],                  
        ENV["GITHUB_CLIENT_SECRET"],                  
        scope: "user"

  config.omniauth :google_oauth2,                 
         ENV["GOOGLE_OAUTH2_CLIENT_ID"],                 
         ENV["GOOGLE_OAUTH2_CLIENT_SECRET"]
lang-ruby


This will pull those credentials from the .env file and pass them into devise.

You will need to update your routes.rb file with:

  devise_scope :member do    
    get "member", to: "members/sessions#new"  
  end

  devise_for :members,             
             controllers: {               
                sessions: "members/sessions",               
                registrations: "members/registrations",               
                omniauth_callbacks: "members/omniauth_callbacks"   
              }lang-ruby

One part of this solution that makes it work so well is that there is an authorizations table added to the database that allows for a single member/user to have multiple authorizations from multiple sources.  The migration for that looks like this:

class CreateAuthorizations < ActiveRecord::Migration[7.0]  
  def change    
    create_table :authorizations do |t|      
    t.integer :member_id      
    t.string :provider      
    t.string :uid      
    t.string :token      
    t.string :secret      
    t.string :username
    t.timestamps    
    end  
  end
end
lang-ruby

In app/models/member.rb:

class Member < ApplicationRecord  
  devise :database_authenticatable, :registerable, :recoverable, 
   :rememberable, :validatable, :omniauthable,
      omniauth_providers: %i[facebook github google_oauth2]

  mount_uploader :profile, ProfileUploader  
  has_many :likes  
  has_many :friends, class_name: "Member"  
  has_many :comments  
  has_and_belongs_to_many :friend_requests
  has_many :authorizations

def self.from_omniauth(auth, current_member)  
    
  authorization = Authorization.where(        
    provider: auth.provider,        
    uid: auth.uid.to_s,        
    token: auth.credentials.token,        
    secret: auth.credentials.secret      
  ).first_or_initialize

if authorization.member.blank?      member = (          
        if current_member.nil?            
           Member.where("email = ?", auth["info"]["email"]).first 
        else            
          current_member 
        end )     
    
    if member.blank?        
      Member.new(          
        email: auth.info.email,          
        password: Devise.friendly_token[0, 10],          
        name: auth.info.name,          
        locations: auth.info.location,          
        profile: auth.info.image        )                
     auth.provider == "twitter" ? user.save!(validate: false) : user.save!      
    end

    authorization.username = auth.info.nickname      
    authorization.member_id = member.id      
    authorization.save!    
   end    
  authorization.member  
 end
end
lang-ruby
The authorization model is much simpler:

class Authorization < ApplicationRecord  
  belongs_to :member
end
lang-ruby
And last, but not least, we will need the devise omniauth_callbacks_controller.  You can generate the file with "rails g devise controllers members omniauth_callbacks"

class Members::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :authenticate_member!
  def all    
    member = Member.from_omniauth(request.env["omniauth.auth"], current_member)
      if member.persisted?      
        flash[:notice] = "you are successfully logged in!!"      
        sign_in_and_redirect(member)    
      else      
        session["devise.member_attributes"] = member.attributes
        puts member.errors.full_messages
        redirect_to new_user_registration_url    
      end  
    end

  def failure    
    super  
  end
  
  alias_method :facebook, :all  
  alias_method :github, :all  
  alias_method :google_oauth2, :all
end
lang-ruby

With all of that in place, I can now login with google, github or by doing so manually.  I will add the facebook login to my project whenever they fix their portal.  For now I'm pretty happy with the two methods in place.  I will soon be adding Oauth to this portfolio app, now that I have the tools to do so.  I hope you have enjoyed this post.  Please feel free to comment and correct any errors I have made along the way.  Feedback and suggestions are always welcome.  Thanks for reading.

Stuart

Comments