DEV Community

1klap
1klap

Posted on • Edited on

Rails 8 authentication with OAuth and addition of a test suite

This is the core part of the full article on my site: https://railsguru.dev/lessons/rails-8-oauth-and-rspec-tests
There is also a repo with the code

Context

With Ruby on Rails 8, the framework ships with a built-in authentication generator! We're extending it with OAuth sign in and a RSpec test suite!

All of the code below is building on top of a project that ran bin/rails generate authentication

Add gems and providers

# 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"
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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
end
Enter fullscreen mode Exit fullscreen mode

Add a new model and controller

bin/rails g model OmniAuthIdentity uid:string provider:string user:references
Enter fullscreen mode Exit fullscreen mode

The next code block is the heart of the whole implementation. It connects the OmniAuthIdentity with the User, does the lookup and the creation

# 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
      redirect_to redirect_path, notice: "Signed in!"
    end
  end

  def failure
    redirect_to new_session_path, alert: "Authentication failed, please try again."
  end
end
Enter fullscreen mode Exit fullscreen mode

Add tests for authentication code

Below is a taste of some specs that test the functionality from the generator.

For example the password reset controller tests:

# 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
Enter fullscreen mode Exit fullscreen mode

Or the session controller tests:

# 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
Enter fullscreen mode Exit fullscreen mode

Shameless plug

The original article can be found here: https://railsguru.dev/lessons/rails-8-oauth-and-rspec-tests
It's posted on railsguru.dev, a learning hub for all things Ruby on Rails that I'm building up. Please check it out and share your thoughts.

You can also follow me on X under @railsguru_dev

Top comments (0)