# Social login with the Rails 8 auth generator

In a previous article, we saw how to implement [social login in a Rails app using the devise gem](https://avohq.io/blog/social-login-rails-google-github), 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 <a href="#application-setup" data-behavior="soft-scroll">skip directly to the app setup</a>.

## 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](/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MzgzLCJwdXIiOiJibG9iX2lkIn19--92421a0fa7283092ca4bad4e2d5ad73e83600c0b/social-login-flow-diagram.webp)

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](https://avohq.io/blog/rails-8-authentication), 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 to 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](/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mzg0LCJwdXIiOiJibG9iX2lkIn19--d2763d9431cbdba7db0a8a4baf37a42aca27cc64/oauth-sign-in-flow.webp)

And the result will look like this:

{{gumlet video_id="678a1994b8fd64e67f7c99d9" width="1668" height="884" /}}

## 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](/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mzg1LCJwdXIiOiJibG9iX2lkIn19--97643e05c2ceeb6fe95ed8cc1e0e122cb3f364ac/database-schema.webp)

So a given user can have multiple connected accounts as long as they match the email associated with the account.

{{info}}
If you need 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.
{{/info}}

## 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:

```bash
$ bin/rails generate model ConnectedService user:references provider uid
```

We run the migrations:

```bash
$ bin/rails db:migrate
```

We now add the `connected_services` association to the `User` class:

```ruby
class User < ApplicationRecord
  has_many :connected_services, dependent: :destroy
end
```

We make sure the `ConnectedService` has the user association too:

```ruby
class ConnectedService < ApplicationRecord
  belongs_to :user
end
```

### Configuring the OmniAuth gem:
We now add the `omniauth` gem to our Gemfile

```ruby
# Gemfile
gem "omniauth"
gem "omniauth-rails_csrf-protection"
```

{{info}}
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.
{{/info}}

Then, we create an `omniauth` initializer and add `Omniauth::Builder` as a middleware with the developer strategy declared only when in development mode:

```ruby
# 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:

```ruby
# Gemfile
gem "omniauth-github", "~> 2.0.0"
gem "omniauth-google-oauth2"
```

After this, we can add GitHub and Google as providers to our initializer:

```ruby
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
```

{{info}}
If you'd like to understand the process of obtaining credentials from the providers. You can check [that section](https://avohq.io/blog/social-login-rails-google-github#obtaining-credentials) 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.
{{/info}}

### 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:

```ruby
# 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:

```ruby
# 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.

{{further_reading slug="sign-in-with-apple-rails" /}}

## The implementation
We start by adding the code for the sign in view accessible at `session/new`:

```erb
<%= 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](/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6Mzg2LCJwdXIiOiJibG9iX2lkIn19--323ebb0312a89837f131ab18fed20f47d852836e/sign-in-view.webp)

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:

```ruby
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.

```ruby
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:

```ruby
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
```

{{info}}
Enjoying this article? You might enjoy Avo, the ultimate [Rails admin](https://avohq.io/rails-admin) and internal tooling gem for Ruby on Rails that can help you build better Rails apps faster.
{{/info}}

### 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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](https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/).

## 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:

```ruby
# 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.

```ruby
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.

{{info}}
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”.
{{/info}}

## 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 <a href="#what-we-will-build" data-behavior="soft-scroll">what we will build section</a>.

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 on 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!
