Rails 8 auth with OAuth and the missing RSpec tests
Summary
Extend the new Rails 8 authentication generator with OAuth and add the missing tests for all of itContext
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!