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