Social login with the Rails 8 auth generator

By Exequiel Rozas

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:

Social login using GitHub as a provider

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:

OAuth auth with Rails flow diagram

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.

Social login with Rails database schema

So a given user can have multiple connected accounts as long as they match the email associated with the account.

If you want to make requests on behalf of the user, e.g: you're building a social media scheduler application, and you configure the proper permissions with the provider, you can add access_token refresh_token and token_expires_at fields so you can track when to ask again for user authorization.

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"

The reason we added the omniauth-rails_csrf-protection gem is to mitigate against a CSRF attack. Under the hood, the gem disables access to the OAuth request phase when using a GET request. So, to make our flow secure, we must make sure that every request to the OAuth request phase is a POST request.

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

If you want to understand the process of obtaining credentials from the providers. You can check that section from the previous article we published. We explain how to obtain credentials for GitHub and Google but the process should be similar for every other provider you wish to use.

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:

Sign in view

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.

If a user has connected GitHub and Google, the message produced by the to_sentence(last_word_connector: " or") call would result in “GitHub or Google”.

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!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.