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"
# 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
# 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
Add a new model and controller
bin/rails g model OmniAuthIdentity uid:string provider:string user:references
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
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
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
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)