In a previous article, we saw how to implement social login in a Rails app using the devise gem, and we actually implemented sign in with Google and GitHub flows.
In this article, we will see how we can build the same feature using Rails 8 built-in authentication, so we can dispense of devise and stay as close to vanilla Rails as possible.
Let's start by understanding how OAuth works. If you like, you can skip directly to the app setup.
How OAuth or social login works
If you're not familiar with it yet, social login is the name given to the implementation of the OAuth2
protocol to authenticate users into an application.
It works by allowing applications to access resources from a third-party (Google, GitHub, LinkedIn, etc.) on behalf of a user without the third-party knowing about the user's credentials for the service in question.
So, when we're talking about “social login”, what it means is that a user can identify itself with our application with a trusted service with a couple of clicks, without the need to think about a password or even think about the account creation beyond those clicks.
When that process happens, the third-party lets us know about the success or failure of the sign-in by using callback URLs that we provide when setting the whole process up.
After a successful sign-in attempt, we can authenticate the user with our application however we see fit: by returning a cookie, an access token, etc.
The flow looks something like this:
Adding an OAuth flow to our application using trustworthy clients like GitHub or Google adds a layer of trust that makes our potential users more prone to signing up to our services.
If you were paying attention to the diagram, the last part of the flow which involves redirecting the user with a session cookie is pretty compatible with the Rails auth generator.
So, we just need to identify a user from an OAuth sign-in and then relate it to a user in our application to later sign her up.
What we will build
Using the new Rails 8 authentication generator, we will implement a simple flow to allow us to authenticate with an email account or an OAuth2
provider, in our case GitHub.
We will be staying as close as possible to the way Rails 8 authentication works out of the box, while adding the necessary attributes and code to make social login work.
The requirements for this feature will be:
- A user should be able to register and authenticate with our application using either an email address and password or any given OAuth providers like Google, GitHub, etc.
- A user should be able to connect any provider from an account view.
- If a user created an account using email but didn't connect any provider, we will ask them to sign in with their email and connect the provider before
The actual flow will look like this:
And the result will look like this:
Architecture
The way the social login feature is commonly implemented is by adding uid
and provider
fields to the users
table.
Devise actually implements it like that in their guides.
That way, we can find the user who matches the uid
returned in a successful OAuth sign-in with a given provider.
However, there are some potential issues with this method:
- Mismatching emails: this can happen if a user has an account with us using a given email and an account with the service provider using another email. This would mean that if the user forgot to sign in with an email and instead tries to use a GitHub account associated to another email for the first time, we wouldn't have a way to know which user is trying to sign in, and we would have to create a new account, if that's the flow like, or redirect a user to ask them to use the original sign-in method.
-
Email only identification: if a user signs in with email and then user a provider for the first time we would identify the user by a matching email, but we don't have a sure way to verify that the provider we're dealing with (remember that the
omniauth
gem lists around 350 OAuth strategies) does a proper job verifying email ownership or securing accounts. So, an unverified account or compromised account can impersonate a user within our application too.
Of course, some applications might just call for something like that, but we will avoid those issues by adding a ConnectedService
model to represent each one of the connections a user has with the OAuth provider.
Because we just want to use OAuth to authenticate users with our application, we won't need to store access or refresh tokens: we just need a provider
and uid
field in the connected_services
table to associate a unique user with a provider.
So a given user can have multiple connected accounts as long as they match the email associated with the account.
Application setup
Assuming we have an application already set-up with the auth generator, we will start this process by adding a ConnectedService
model to keep track of the accounts a given user has access to:
$ bin/rails generate model ConnectedService user:references provider uid
We run the migrations:
$ bin/rails db:migrate
We now add the connected_services
association to the User
class:
class User < ApplicationRecord
has_many :connected_services, dependent: :destroy
end
We make sure the ConnectedService
has the user association too:
class ConnectedService < ApplicationRecord
belongs_to :user
end
Configuring the OmniAuth gem:
We now add the omniauth
gem to our Gemfile
# Gemfile
gem "omniauth"
gem "omniauth-rails_csrf-protection"
Then, we create an omniauth
initializer and add Omniauth::Builder
as a middleware with the developer strategy declared only when in development mode:
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :developer if Rails.env.development?
end
Now, we can add the specific strategy gems. In our case, we need to add a GitHub and a Google strategy:
# Gemfile
gem "omniauth-github", "~> 2.0.0"
gem "omniauth-google-oauth2"
After this, we can add GitHub and Google as providers to our initializer:
Rails.application.config.middleware.use OmniAuth::Builder do
provider :developer if Rails.env.development?
provider :github, Rails.application.credentials.dig(:github, :client_id), Rails.application.credentials.dig(:github, :client_secret), scope: "user"
provider :google_oauth2, Rails.application.credentials.dig(:google, :client_id), Rails.application.credentials.dig(:google, :client_secret), scope: "user"
end
Routes and controller
The next step is configuring the routes to handle the user sign-in. Because we're adding an OAuth flow on top of the e-mail/password flow, we will be adding a name spaced controller:
# config/routes.rb
get "auth/:provider/callback", to: "omni_auth/sessions#create"
Consider that these are the callback routes we should be adding when setting the provider credentials.
Internally, the omniauth
gem sets a POST /auth/:provider
route so we should have both /auth/github
and /auth/google_oauth2
routes. Each of them initiates the request phase of the OAuth flow.
Now we add a new SessionsController
under the OmniAuth namespace:
# app/controllers/omniauth/sessions_controller.rb
class OmniAuth::SessionsController < ApplicationController
allow_unauthenticated_access only: [:create]
def create
auth_info = request.env['omniauth.auth']
# Rest of the code...
end
end
The auth_info
variable will contain the information returned by the service provider if the attempt was successful.
Using the information from a successful OAuth sign in we will build the feature.
Let's start by implementing the GitHub login.
The implementation
We start by adding the code for the sign in view accessible at session/new
:
<%= content_for :body_class, "bg-gray-100" %>
<div class="max-w-2xl mx-auto md:w-2/3 w-full h-screen pt-24">
<div class="px-8 pt-6 pb-10 bg-white border border-gray-300 rounded-xl">
<h1 class="font-bold text-2xl">Sign in</h1>
<%= form_with url: session_url, class: "contents space-y-6" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block rounded-md border border-gray-400 placeholder:text-sm px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block rounded-md border border-gray-400 placeholder:text-sm px-3 py-2 mt-2 w-full" %>
</div>
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="flex w-full items-center justify-between">
<div class="w-full">
<%= form.submit "Sign in", class: "rounded-full w-full py-2 px-5 bg-indigo-500 text-white inline-block font-medium cursor-pointer" %>
</div>
</div>
</div>
<% end %>
<div class="w-full flex flex-col items-center justify-center my-4">
<div class="h-px w-full bg-gray-300 relative my-4">
<span class="text-gray-700 bg-white px-4 text-sm absolute top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2">Or sign in using</span>
</div>
<div class="mt-3 flex justify-center items-center gap-4">
<%= button_to "/auth/github", method: :post, data: {turbo: false}, class: "flex w-full items-center justify-center px-8 py-2 text-sm font-medium rounded-md bg-gray-900 text-white border border-gray-900 hover:bg-gray-800 duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white cursor-pointer" do %>
<%= image_tag "github-white.svg", class: "w-5 h-5" %>
<span class="ml-2">GitHub</span>
<% end %>
<%= form_tag "/auth/google_oauth2", method: :post, data: {turbo: false}, class: "flex w-full items-center justify-center px-8 py-2 text-sm font-medium rounded-md bg-white text-white border border-gray-400 hover:bg-gray-100 duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white cursor-pointer" do %>
<%= image_tag "google.png", class: "w-5 h-5" %>
<span class="ml-2 text-gray-900">Google</span>
<% end %>
<% if Rails.env.development? %>
<%= button_to "/auth/developer", method: :post, data: {turbo: false}, class: "flex w-full items-center justify-center px-8 py-2 text-sm font-medium rounded-md bg-white text-white border border-gray-400 hover:bg-gray-100 duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white cursor-pointer" do %>
<span class="ml-2 text-gray-900">Developer</span>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
Which will look like something like this:
Next, we will add the code to the SessionsController
.
The first thing we will do is setting the connecting service— the provider — if we have a matching record in our database:
class OmniAuth::SessionsController < ApplicationController
before_action :set_service, only: [:create]
# rest of the code
private
def set_service
@service = ConnectedService.find_by(provider: user_info.provider, uid: user_info.uid)
end
end
Next, we will set the user by either:
- Retrieving it from the current session if the user is signed in.
- Retrieving it from the
ConnectedService
instance if there's any. - Retrieving it from our database if a user with a matching
email_address
exists. - Creating it using the information provided by the authentication hash.
class OmniAuth::SessionsController < ApplicationController
# rest of the code
before_action :set_user, only: [:create]
private
# rest of the code
def set_user
user = resume_session.try(:user)
if user.present?
@user = user
elsif @service.present? # @service was set up before
@user = @service.user
elsif User.find_by(email_address: user_info.dig(:info, :email)).present?
service_methods = ConnectedService.where(user_id: User.find_by(email_address: user_info.dig(:info, :email))).pluck(:provider).map(&:to_s).join(", ")
flash[:notice] = "There's already an account with this email address. Please sign in with it using your #{service_methods} account to associate it with this service."
redirect_to new_session_path
else
@user = create_user
end
end
def create_user
email = user_info.dig(:info, :email)
username = user_info.dig(:info, :name) || user_info.dig(:info, :email).split('@').first
User.create!(email_address: email, username: username, password: SecureRandom.hex(10))
end
end
As you can see, if the user is signed in we set it to the user in the session; otherwise we try to fetch it from the ConnectedService
instance if it exists.
If that fails, we try to find the user by email_address
in our database. If there's one, we proceed to inform the user attempting to sign in that a previous authorization is required to sign in with that.
Now, we add the create
action code:
class OmniAuth::SessionsController < ApplicationController
# rest of the code
def create
if !@service.present?
@service = @user.connected_services.create!(provider: user_info.provider, uid: user_info.uid)
end
if Current.user.present?
flash[:notice] = "#{@service.provider.to_s.humanize} connected"
redirect_to account_path
else
start_new_session_for @user
flash[:notice] = "You have been signed in. Welcome to JobLister"
redirect_to root_path
end
end
# rest of the code
end
The failure flow
By default, the omniauth
gem raises an exception in development when the authentication fails.
We could rescue from that exception and handle the flow in the create
action, but I feel that handling it in a separate action is clearer when it comes to reading the code again in the future.
To make that happen, we need to add some configuration to our initializer:
# config/initializers/omniauth.rb
OmniAuth.config.on_failure = Proc.new do |env|
OmniAuth::FailureEndpoint.new(env).redirect_to_failure
end
If we take a look under the hood in the gem's code, what the redirect_to_failure
method does is generating a 302 Moved
redirection using the following as a path:
# lib/omniauth/failure_endpoint.rb
new_path = "#{env['SCRIPT_NAME']}#{strategy_path_prefix}/failure?message=#{Rack::Utils.escape(message_key)}#{origin_query_param}#{strategy_name_query_param}"
Which, by default, will generate a redirect to the /auth/failure
, so we will add an endpoint for that route:
# config/routes.rb
get "auth/failure", to: "omni_auth/sessions#failure"
In the controller, we add the failure
action and handle the access_denied
error message, which might be the most common one once we have everything working and credentials approved:
# app/controllers/omniauth/sessions_controller.rb
class OmniAuth::SessionsController < ApplicationController
allow_unauthenticated_access_to only: [:create, :failure]
# rest of the code
def failure
if params[:message] == "access_denied"
flash[:alert] = "You cancelled the sign in process. Please try again."
else
flash[:alert] = "There was an issue with the sign in process. Please try again."
end
redirect_to new_session_path
end
end
Now, if the user denies access to our application, we show them a message to better communicate what happened.
Consider that there are many possible errors with the OAuth flow. If you would like to handle them individually for a better integration, check the OAuth possible errors list.
A note about the flow
You may have noticed that if a user signs in using a provider first and then tries to sign in using an email address, the flow returns a “Try another email address or password” error message.
This message is confusing and might result in users abandoning our application if they don't remember how they signed up.
To improve this, we will modify the SessionsController
that handles the email/password flow to improve the error message.
The create
action looks like this right now:
# app/controllers/sessions_controller.rb
def create
user = User.authenticate_by(params.permit(:email_address, :password))
if user
start_new_session_for user
flash[:notice] = "You have been signed in."
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
Because we know for sure that this action only gets executed with the email/password flow, we can check if the user has any connected service and inform them about that in the flash message.
def create
user = User.authenticate_by(params.permit(:email_address, :password))
if user
start_new_session_for user
flash[:notice] = "You have been signed in."
redirect_to after_authentication_url
else
local_user = User.find_by(email_address: params[:email_address])
if local_user.connected_services.any?
flash[:alert] = "You've previously signed in using you #{connected_services_string(local_user)} account. Please use that to sign in."
else
flash[:alert] = "Try another email address or password."
end
redirect_to new_session_path
end
end
private
def connected_services_string(user)
user.connected_services.map(&:provider).to_sentence(last_word_connector: " or")
end
This will result in a better error message and user experience in general.
TL;DR
To allow our users to sign in to our application using providers like GitHub or Google, we must implement an OAuth flow.
For that purpose, we use the omniauth
gem to handle the general flow and a different “strategy” gem for each one of the services.
To avoid some common pitfalls of storing the OAuth information in the users
table, we add a ConnectedService
model.
This model represents each one of the services a user allowed us to access on their behalf to authenticate with us.
Because of the fact we're using the Rails auth generator, after a successful sign-in, we initiate a session using the start_new_session_for
method.
The actual implementation of this feature is mostly centered around setting the User
we want to authenticate and deciding whether we should proceed with authentication or not based on the factors explained in the user flow in the what we will build section.
Finally, to improve user experience, we handle the most common failure, which is denied access.
All in all, the actual social login feature using the Rails auth generator is about the OAuth part of it and adding some logic in top of it to conclude the feature by starting a new session for the user.
I hope you found the article useful and, as usual, have a good one and happy coding!