Rails 8 auth with OAuth and the missing RSpec tests

by Kim Laplume

Summary

Extend the new Rails 8 authentication generator with OAuth and add the missing tests for all of it

Context

Rails 8 ships with a new built-in generator that helps you to get started with authentication. However, while it covers the bases, often something more is needed. OAuth Sign In reduces friction for the user and removes (to a degree) the burden of password management. Better test coverage helps with developer productivity and product reliability.

The goal of this lesson is to make those steps as easy as possible.

The whole code for this lesson can be found in this repo

Step 0: Getting started

We're starting with a brand new Rails 8 project and running the authentication generator with the following commands

$> rails _8.0.0_ new l001-oauth-and-tests
$> bin/rails g authentication

Since we're adding an additional sign-in mechanism, i added a field to the session migration to indicate the source of the session. As a consequence, the start_new_session method in the Authentication concern needs to account for that, an we adjust the calling of the method in SessionsController as well.

# db/migrate/XXXXXXXXXXXXXX_create_sessions.rb

create_table :sessions do |t|
  # ...
  t.string :source
  # ...
end
# app/controllers/concerns/authentication.rb

module Authentication
  # ...
  def start_new_session_for(user, source: nil)
    # TODO: add source below
    user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, source:).tap do |session|
      Current.session = session
      cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
    end
  end
  # ...
# app/controllers/concerns/authentication.rb

class SessionsController < ApplicationController
  # ...
  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user, source: :password_login # TODO: change here
      redirect_to after_authentication_url
    else
      redirect_to new_session_path, alert: "Try another email address or password."
    end
  end
  #...
end

Step 1: Add Omniauth gems

Omniauth is the gem that allows us to integrate with different OAuth providers without reinventing the wheel. There is one gem per OAuth provider that implements their specific behavior. Check out the full list of OmniAuth Strategies.

# Gemfile

gem "omniauth", "~> 2.1", ">= 2.1.2"
gem "omniauth-rails_csrf_protection", "~> 1.0", ">= 1.0.2"
gem "omniauth-google-oauth2", "~> 1.2"
gem "omniauth-github", "~> 2.0.0"
$> bundle install

Step 2: OmniAuth providers and routes

Create a new initializer that sets up the individual providers, the credentials will be added at the end.

# config/initializers/omniauth_providers.rb

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :developer if Rails.env.development? || Rails.env.test?
  provider :google_oauth2, Rails.application.credentials.dig(:oauth, :google, :client_id), Rails.application.credentials.dig(:oauth, :google, :client_secret)
  provider :github, Rails.application.credentials.dig(:oauth, :github, :client_id), Rails.application.credentials.dig(:oauth, :github, :secret), scope: "user:email"
end

Wire the routes that OmniAuth uses with the providers into our own controller, which will be added in the next step.

# config/routes.rb

Rails.application.routes.draw do
  # ...
  get "/auth/:provider/callback" => "sessions/omni_auths#create", as: :omniauth_callback
  get "/auth/failure" => "sessions/omni_auths#failure", as: :omniauth_failure
  # ...
  root "homes#index"
end

We also need a very simple controller to act as the root page, we'll let the user sign in and out and show who the current user is

# app/controllers/homes_controller.rb

class HomesController < ApplicationController
  allow_unauthenticated_access

  def index
  end
end
<!-- app/views/homes/index.html.erb -->

<h1>Homes#index</h1>
<p>Find me in app/views/homes/index.html.erb</p>

<% if authenticated? %>
  <%= button_to "Sign out", session_path, method: :delete %>
<% else %>
  <%= link_to "Sign in", new_session_path %>
<% end %>

<div>
  Current.user&.email_address = <%= Current.user&.email_address %>
</div>

Step 3: Add OmniAuthIdentity model and OmniAuth controller

We want to add one model that identifies a given user when he is signing in with an OAuth provider, that is decoupled from the User model itself. This is useful because a User might also want to connect with OAuth after he signed up, or connect with multiple providers.

$> bin/rails g model OmniAuthIdentity uid:string provider:string user:references

This controller is the heart of the OAuth signup, it looks up Users to find an existing one or create a new one. We cover also the logic that if a User connects with OAuth when he is already signed in, that we also link those models together, although this flow is not doable by clicking through the site.

# app/controllers/sessions/omni_auths_controller.rb

class Sessions::OmniAuthsController < ApplicationController
  allow_unauthenticated_access only: [ :create, :failure ]

  def create
    auth = request.env["omniauth.auth"]
    uid = auth["uid"]
    provider = auth["provider"]
    redirect_path = request.env["omniauth.params"]&.dig("origin") || root_path

    identity = OmniAuthIdentity.find_by(uid: uid, provider: provider)
    if authenticated?
      # User is signed in so they are trying to link an identity with their account
      if identity.nil?
        # No identity was found, create a new one for this user
        OmniAuthIdentity.create(uid: uid, provider: provider, user: Current.user)
        # Give the user model the option to update itself with the new information
        Current.user.signed_in_with_oauth(auth)
        redirect_to redirect_path, notice: "Account linked!"
      else
        # Identity was found, nothing to do
        # Check relation to current user
        if Current.user == identity.user
          redirect_to redirect_path, notice: "Already linked that account!"
        else
          # The identity is not associated with the current_user, illegal state
          redirect_to redirect_path, notice: "Account mismatch, try signing out first!"
        end
      end
    else
      # Check if identity was found i.e. user has visited the site before
      if identity.nil?
        # New identity visiting the site, we are linking to an existing User or creating a new one
        user = User.find_by(email_address: auth.info.email) || User.create_from_oauth(auth)
        identity = OmniAuthIdentity.create(uid: uid, provider: provider, user: user)
      end
      start_new_session_for identity.user, source: provider
      redirect_to redirect_path, notice: "Signed in!"
    end
  end

  def failure
    redirect_to new_session_path, alert: "Authentication failed, please try again."
  end
end

The User model needs to be touched at this point, because we're calling User.create_from_oauth and User#signed_in_with_oauth in the controller. The reason is to keep the responsibilities separated, however there can be different solutions to achieve the same result.

Important to note: when a User is created through OAuth sign in, we create a long and random strong password for him, that he will not be aware of. This is required because the field is not nullable and the database would complain if we try to create a user without one. Having the password_digest field is technically possible but requires disabling the validations from has_secure_password (see commits in the repo for more details).

We also add validations on email_address and password, the second is restricted to the context :registration and :password_change, more on that below. And we add the reverse association to OmniAuthIdentity with the has_many declaration.

# app/models/user.rb

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy
  has_many :omni_auth_identities, dependent: :destroy

  validates :email_address, presence: true,
            format: { with: URI::MailTo::EMAIL_REGEXP },
            uniqueness: { case_sensitive: false }
  validates :password, on: [ :registration, :password_change ],
            presence: true,
            length: { minimum: 8, maximum: 72 }

  normalizes :email_address, with: ->(e) { e.strip.downcase }

  def self.create_from_oauth(auth)
    email = auth.info.email
    user = self.new email_address: email, password: SecureRandom.base64(64).truncate_bytes(64)
    # TODO: you could save additional information about the user from the OAuth sign in
    # assign_names_from_auth(auth, user)
    user.save
    user
  end

  def signed_in_with_oauth(auth)
    # TODO: same as above, you could save additional information about the user
    # User.assign_names_from_auth(auth, self)
    # save if first_name_changed? || last_name_changed?
  end
end

With the above mentioned password validation on a given context, we need to retouch the PasswordsController. This is required, because we can't save with a context with the update method that was generated out-of-the-box.
If you add a Registrations controller down the line yourself, you should to something similar with the context :registration.

# app/controllers/passwords_controller.rb

class PasswordsController < ApplicationController
  # ...
  def update
    @user.assign_attributes(params.permit(:password, :password_confirmation))
    if @user.save(context: :password_change) # This is where we set the context for the validation
      redirect_to new_session_path, notice: "Password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: @user.errors.map(&:full_message).join(", ")
    end
  end
  #...
  end

Step 4: Add credentials and sign in buttons

Now, you just need to plug in your OAuth credentials for the different providers, in my example Github and Google. The respective pages are (currently):
- Github
- Google Cloud Console > Search for "Google Auth Platform" > Clients
There is usually a verification step that you need to go through before the OAuth clients are unlocked to the public. But you should be able to get you credentials already and plug them in your Rails app with the following command. Remember to restart the rails server afterwards, since initializers are not reloaded automatically. In the meantime, the "Developer" (fake) provider allows you to test your integration.

$> EDITOR=vim bin/rails credentials:edit

In the repo, the master.key is commited with the project to make editing the file a bit more convenient, you should not do this for your own projects in general.

Finally, in order to lead users to the OAuth sign in, you need to place the right buttons somewhere, I propose the login page. Those buttons should respect the branding of the respective OAuth provider, or your verification might fail.

<!-- app/views/sessions/new.html.erb -->

<!-- .... -->
<%= link_to "Forgot password?", new_password_path %>

<% if Rails.env.development? %>
  <%= form_tag "/auth/developer", method: :post, data: { turbo: false } do %>
    <button type="submit">
      Login with Developer
    </button>
  <% end %>
<% end %>

<%= form_tag "/auth/google_oauth2", method: :post, data: { turbo: false } do %>
  <button type="submit">
    Login with Google
  </button>
<% end %>

<%= form_tag "/auth/github", method: :post, data: { turbo: false } do %>
  <button type="submit">
    Login with Github
  </button>
<% end %>

Step 5: The missing RSpec test suite

Most files should be pretty self-explanatory, since RSpec makes them readable as almost plain English.

Some fixtures that cover our use cases.

# spec/fixtures/omni_auth_identities.yml

existing:
  user: existing
  provider: github
  uid: 12345
# spec/fixtures/users.yml

existing:
  email_address: existing@example.com
  password_digest: <%= BCrypt::Password.create("password") %>
existing_no_pass:
  email_address: existing_no_pass@example.com
  password_digest: <%= BCrypt::Password.create(SecureRandom.base64(64).truncate_bytes(64)) %>

For the User model, we test validations and authenticate_by method.

# spec/models/user_spec.rb

require 'rails_helper'

RSpec.describe User, type: :model do
  fixtures :users
  let(:existing_user) { users :existing }
  let(:existing_user_without_password) { users :existing_no_pass }

  it "requires a valid email" do
    user = User.new(email_address: "", password: "password")
    expect(user).to_not be_valid
    user.email_address = nil
    expect(user).to_not be_valid
    user.email_address = "invalid"
    expect(user).to_not be_valid
    user.email_address = "johndoe@example.com"
    expect(user).to be_valid
  end

  it "requires a unique email" do
    expect(existing_user.persisted?).to be_truthy
    user = User.new(email_address: "existing@example.com", password: "password")
    expect(user).to be_invalid
  end

  context "on registration" do
    it "requires a valid password" do
      user = User.new(email_address: "johndoe@example.com")
      expect(user.valid?(:registration)).to be_falsey
      user.password = 'a' * 7
      expect(user.valid?(:registration)).to be_falsey
      user.password = 'é' * 72 # Too long in bytesize for bcrypt
      expect(user.valid?(:registration)).to be_falsey
      user.password = 'a' * 73
      expect(user.valid?(:registration)).to be_falsey
      user.password = 'a' * 72
      expect(user.valid?(:registration)).to be_truthy
    end
  end

  context "on authentication" do
    it "only accepts the right password" do
      expect(User.authenticate_by(email_address: "existing@example.com", password: nil)).to be_falsey
      expect(User.authenticate_by(email_address: "existing@example.com", password: "")).to be_falsey
      expect(User.authenticate_by(email_address: "existing@example.com", password: "wrong")).to be_falsey
      expect(User.authenticate_by(email_address: "existing@example.com", password: "password")).to_not be_nil
      expect(User.authenticate_by(email_address: "existing_no_pass@example.com", password: "password")).to be_falsey
      expect(User.authenticate_by(email_address: "existing_no_pass@example.com", password: "")).to be_falsey
      expect(User.authenticate_by(email_address: "existing_no_pass@example.com", password: nil)).to be_falsey
      expect do
        User.authenticate_by(email_address: "existing_no_pass@example.com")
      end.to raise_exception(ArgumentError)
    end
  end
end

Moving on the request specs (aka controller tests). We're covering all the critical routes and verify if the outcome matches our expectations.

The check in omni_auths_spec.rb against the body, whether the user is signed in or not, is of course a little brittle, feel free to replace this later with a get of a route that need authorization down later and check the response code.

# spec/requests/sessions/omni_auths_spec.rb

require 'rails_helper'

RSpec.describe "Sessions:OmniAuths", type: :request do
  fixtures :users, :omni_auth_identities
  let(:existing) { users(:existing) }
  let(:existing_no_identity) { users(:existing_no_pass) }

  describe "GET /auth/:provider/callback" do
    context "existing user with omni_auth_identity" do
      it "creates no user and signs in" do
        mock_omniauth_provider("github", email: existing.email_address, uid: existing.omni_auth_identities.first.uid)
        existing.sessions.destroy_all

        expect do
          get "/auth/github/callback"
        end.to change { User.count }.by(0)
                                    .and change { OmniAuthIdentity.count }.by(0)
        expect(existing.sessions.count).to eq 1
        expect(response).to redirect_to root_path
        follow_redirect!
        expect(response.body).to include "Current.user&.email_address = #{existing.email_address}"
      end
    end

    context "existing user without omni_auth_identity" do
      it "creates no user and signs in" do
        mock_omniauth_provider("github", email: existing_no_identity.email_address, uid: 45678)
        existing_no_identity.sessions.destroy_all

        expect do
          get "/auth/github/callback"
        end.to change { User.count }.by(0)
                                    .and change { OmniAuthIdentity.count }.by(1)
        expect(existing_no_identity.omni_auth_identities.first).to have_attributes uid: "45678", provider: "github"
        expect(existing_no_identity.sessions.count).to eq 1
        expect(response).to redirect_to root_path
        follow_redirect!
        expect(response.body).to include "Current.user&.email_address = #{existing_no_identity.email_address}"
      end
    end

    context "new user" do
      it "creates a new user and signs in" do
        mock_omniauth_provider("github", email: "new@example.com", uid: 98765)

        expect do
          get "/auth/github/callback"
        end.to change { User.count }.by(1)
                                    .and change { OmniAuthIdentity.count }.by(1)
        new_user = User.order(created_at: :desc).take
        expect(new_user.omni_auth_identities.first).to have_attributes uid: "98765", provider: "github"
        expect(new_user.sessions.count).to eq 1
        expect(response).to redirect_to root_path
        follow_redirect!
        expect(response.body).to include "Current.user&.email_address = new@example.com"
      end
    end
  end

  def mock_omniauth_provider(provider, email:, uid:)
    OmniAuth.config.test_mode = true
    OmniAuth.config.mock_auth[provider.to_sym] = OmniAuth::AuthHash.new({
                                                                          provider: provider,
                                                                          uid: uid,
                                                                          info: {
                                                                            email: email
                                                                          }
                                                                        })
  end
end
# spec/requests/homes_spec.rb

require 'rails_helper'

RSpec.describe "Homes", type: :request do
  describe "GET /" do
    it "returns http success" do
      get "/"
      expect(response).to have_http_status(:success)
    end
  end

end

Here we're testing mail delivery and token validity (in the mail and over time)

# spec/requests/passwords_spec.rb

require 'rails_helper'
require 'active_support/testing/time_helpers'
include ActiveSupport::Testing::TimeHelpers

RSpec.describe "Password", type: :request do
  fixtures :users
  let(:user) { users(:existing) }
  before(:all) do
    ActionMailer::Base.deliveries.clear
  end

  describe "GET /passwords/new" do
    it "returns http success" do
      get "/passwords/new"
      expect(response).to have_http_status(:success)
    end
  end

  describe "POST /passwords" do
    it "creating a password reset sends an email and show instructions" do
      # TODO: need to decide whether to enqueue or perform jobs
      # for the moment, request spec will perform jobs, while model spec will enqueue jobs
      perform_enqueued_jobs do
        post passwords_path, params: { email_address: user.email_address }
      end
      # expect do
      #   post passwords_path, params: { email_address: user.email_address }
      # end.to have_enqueued_email(PasswordMailer, :reset).exactly(:once)

      expect(response).to redirect_to new_session_path
      follow_redirect!
      assert_select "div", text: "Password reset instructions sent (if user with that email address exists)."

      expect(ActionMailer::Base.deliveries.size).to eq(1)

      ActionMailer::Base.deliveries.first.tap do |mail|
        content = mail.html_part.body.to_s
        token = content.match(/passwords\/(.+)\/edit"/)[1]
        expect(User.find_by_password_reset_token!(token)).to eq(user)
      end
    end
  end

  describe "GET /passwords/:token/edit" do
    it "returns http success" do
      get edit_password_path(user.password_reset_token)
      expect(response).to have_http_status(:success)
      assert_select "form"
    end
  end

  describe "PUT /passwords/:token" do
    it "changes to users password with a valid token" do
      token = user.password_reset_token

      expect do
        patch password_path(token), params: { password: "W3lcome?", password_confirmation: "somethingelse" }
      end.not_to change { user.password_digest }
      expect(response).to redirect_to(edit_password_path(token))

      expect do
        patch password_path(token), params: { password: "short", password_confirmation: "short" }
      end.not_to change { user.password_digest }
      expect(response).to redirect_to(edit_password_path(token))

      expect do
        patch password_path(token), params: { password: "W3lcome?" }
      end.to change { user.reload.password_digest }
      expect(response).to redirect_to(new_session_path)
      expect(User.authenticate_by(email_address: user.email_address, password: "W3lcome?")).to_not be_nil

      # Reset the token after a successful password change
      token = user.password_reset_token

      expect do
        patch password_path(token), params: { password: "W3lcome?", password_confirmation: "W3lcome?" }
      end.to change { user.reload.password_digest }
      expect(response).to redirect_to(new_session_path)
      expect(User.authenticate_by(email_address: user.email_address, password: "W3lcome?")).to_not be_nil
    end
  end

  it "does not change a user's password with an expired token" do
    token = user.password_reset_token
    travel_to 16.minutes.from_now
    expect do
      patch password_path(token), params: { password: "W3lcome?", password_confirmation: "W3lcome?" }
    end.not_to change { user.password_digest }
    expect(response).to redirect_to(new_password_path)
  end
end
# spec/requests/sessions_spec.rb

require 'rails_helper'

RSpec.describe "Sessions", type: :request do
  fixtures :users
  let(:user) { users(:existing) }

  describe "GET /session/new" do
    it "returns http success" do
      get "/session/new"
      expect(response).to have_http_status(:success)
    end
  end

  describe "POST /session" do
    it "sign a user in when credentials are valid" do
      user.sessions.destroy_all
      expect do
        post session_path, params: { email_address: user.email_address, password: "password" }
      end.to change { user.sessions.count }.from(0).to(1)
    end

    it "does not sign a user in when credentials are invalid" do
      user.sessions.destroy_all
      expect do
        post session_path, params: { email_address: user.email_address, password: "wrong-password" }
      end.not_to change { user.sessions.count }
    end
  end

  describe "DELETE /session" do
    it "sign a user out" do
      post session_path, params: { email_address: user.email_address, password: "password" }
      expect(response).to redirect_to root_path
      expect(user.reload.sessions.count).to eq(1)
      follow_redirect!

      expect do
        delete session_path
      end.to change { user.sessions.count }.from(1).to(0)
      expect(response).to redirect_to new_session_path
    end
  end
end

With this, we extended the default authentication quite a bit. With a robust test suite set in place, we can envision tackling problems like registration and then email verification.

I hope you like the content, feel free to reach out on Github or X to share your feedback!