From custom-made to plug-and-play forms of authentication, Ruby developers have plenty to choose from these days. Yet, as you may know, building your own solution can be costly and dangerous. If Devise is the de facto standard for most teams, an alternative might simplify the lives of most.
This article will cover the setup and use of Auth0 in a Ruby on Rails application, including everything you need to get going properly, from handling roles to relying on multiple providers to authenticate users.
Getting Started
Here's what we need to get started:
- An Auth0 account
- A Ruby on Rails application (version 7.x onwards)
Auth0 is a third-party authentication service with a free tier that lets you handle up to 7,000 users. That is plenty to get you started, and its pricing is reasonable if you need more advanced features.
Configuring Our Ruby App in Auth0
Since your application will rely on Auth0 to authenticate users through redirects and calls, we must ensure it stays secure.
In our Auth0 account, let's create an application within a tenant. You can create multiple tenants in an account to separate:
- Domain names.
- Different environments (development, production, etc).
- The country or region within which data will be stored.
Once you have created your first tenant, you can build an application. That's where we'll start.
Head to the "Settings" tab in the application panel. You need to copy and paste the following and save it to a safe place:
- The domain name:
app-name.[eu,us,..].auth0.com
- The client's ID
- The client's Secret
We must also fill in the following Application URIs. We'll use these values for our local development setup:
- Allowed Callback URLs:
http://localhost:3000/auth/auth0/callback
(the URL Auth0 will redirect to after authentication). - Allowed Logout URLs:
http://localhost:3000
(the URL Auth0 will redirect to after someone logs out).
Let's go ahead and configure our app.
Preparing Our Ruby on Rails Application
You can start with a vanilla Ruby on Rails application using the rails new
command.
Let's generate one that relies on SQLite3 for the database and Tailwind for the CSS library. Skip the installation of mini-test and name the application "Auth0 article":
cd ~/my_projects
rails new -d sqlite3 -c tailwind -T auth0_article
Create a User model with just a few attributes:
cd auth0_article
bin/rails db:create
bin/rails generate model User email name
bin/rails db:migrate
This builds a simple but efficient base for our application.
Adding the Auth0 Gem to the App
You'll need omniauth-auth0
and omniauth-rails_csrf_protection
to use Auth0 in your Ruby on Rails application.
gem 'omniauth-auth0', '~> 3.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
Configuring Auth0
We need to create a tiny configuration file (config/auth0.yml
) to store credentials in our development environment.
development:
auth0_domain: <YOUR AUTH0 APPLICATION DOMAIN NAME>
auth0_client_id: <YOUR AUTH0 APPLICATION CLIENT ID>
auth0_client_secret: <YOUR AUTH0 APPLICATION CLIENT SECRET>
We can also rely on environment variables here by using some erb:
development:
auth0_domain: <%= ENV['AUTH0_APPLICATION_DOMAIN'] %>
auth0_client_id: <%= ENV['AUTH0_APPLICATION_CLIENT_ID'] %>
auth0_client_secret: <%= ENV['AUTH0_APPLICATION_CLIENT_SECRET'] %>
Of course, relying on Ruby on Rails credentials ensures that things stay more up-to-date.
This file will be used in the Auth0 initializer (config/initializers/auth0.rb
), which we will now create:
AUTH0_CONFIG = Rails.application.config_for(:auth0)
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:auth0,
AUTH0_CONFIG['auth0_client_id'],
AUTH0_CONFIG['auth0_client_secret'],
AUTH0_CONFIG['auth0_domain'],
callback_path: '/auth/auth0/callback',
authorize_params: {
scope: 'openid profile'
}
)
end
As you can see here, we are using
Rails.application.config_for
to load up the content of a YAML file as a convenient hash. We could replace this with any secret handling library, including Rails encrypted credentials storage.
Note the following:
- The provider name (
:auth0
) - The three items from the YAML file, read and used through the
AUTH0_CONFIG
hash. - The
callback_path
key and value, matching the one we configured in the Auth0 interface. - The
authorize_params
and thescope
key inside it; we will come back to that later.
We need to create the interface and routing elements to allow for authentication.
Setting Up Routes and UI with Tailwind
In this example, we will work with a Ruby on Rails application using version 7.1 of the framework with TailwindCSS.
Use the following command:
rails new -d sqlite3 -c tailwind -T myApp
We can then add two controllers with the index action, preparing to test the authentication process:
bin/rails g controller public index
bin/rails g controller private index
With those two commands, the following are created:
- Both
public_controller.rb
andprivate_controller.rb
controllers, with the index action ready to use - Both related routes
- The related views
Let's add the Auth0 controller to handle the callbacks and failures. Create the controller file (bin/rails g controller auth0
) and add the following:
# ./app/controllers/auth0_controller.rb
class Auth0Controller < ApplicationController
# this will happen in case of success
def callback
auth_info = request.env['omniauth.auth']
session[:userinfo] = auth_info['extra']['raw_info']
redirect_to '/private/index'
end
# this will happen in case of failure
def failure
@error_msg = request.params['message']
redirect_to '/public_index'
end
# logout route
def logout
reset_session
redirect_to logout_url, allow_other_host: true
end
private
def logout_url
request_params = {
returnTo: root_url,
client_id: AUTH0_CONFIG['auth0_client_id']
}
URI::HTTPS.build(host: AUTH0_CONFIG['auth0_domain'], path: '/v2/logout', query: request_params.to_query).to_s
end
end
Then update the routes to use those three actions:
./config/routes.rb
Rails.application.routes.draw do
# ..
get '/auth/auth0/callback' => 'auth0#callback'
get '/auth/failure' => 'auth0#failure'
get '/auth/logout' => 'auth0#logout'
root "public#index"
end
And then we can add Login
and Logout
buttons in the public and private views, respectively:
# app/views/public/index.html.erb
<div>
<h1 class="font-bold text-4xl">Public#index</h1>
<p>Find me in app/views/public/index.html.erb</p>
<%= button_to 'Login', '/auth/auth0', method: :post, data: { turbo: false }, class: "rounded bg-sky-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500" %>
</div>
# app/views/private/index.html.erb
<div>
<h1 class="font-bold text-4xl">Private#index</h1>
<p>Find me in app/views/private/index.html.erb</p>
<%= button_to 'Logout', '/auth0/logout', method: :get, data: { turbo: false }, class: "rounded bg-sky-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500" %>
</div>
You can now go to 'http://localhost:3000'. Use the Login
button and you'll be redirected to the private page. There, you will find a Logout
button.
A couple of things are missing, though:
- Data from a user's profile
- Identifying a user and handling authorization
Working with Scopes and Data
Let's take a few steps back and bring back the initializer (config/initializers/auth0.rb
):
AUTH0_CONFIG = Rails.application.config_for(:auth0)
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:auth0,
AUTH0_CONFIG['auth0_client_id'],
AUTH0_CONFIG['auth0_client_secret'],
AUTH0_CONFIG['auth0_domain'],
callback_path: '/auth/auth0/callback',
authorize_params: {
scope: 'openid profile'
}
)
end
The critical piece here is the scope: openid profile
. This tells Auth0 we are interested in a few pieces of information, namely:
- The provider name (Auth0)
- A
uid
: A unique identifier to match a user - An
info
hash: Containing a name, URL to a profile picture, and an empty 'email' key - An
extra_info
hash: Again, with a name and profile picture, but also a pair of given and family names
Read more on the topic of scopes in Auth0's documentation.
The above information is valuable, and you should ensure that, at least upon first login, you copy that data into a table in your application.
To do so, we need to use the content of the request-response in the auth0_controller
and, specifically, in the callback
action:
def callback
# all the data sent by auth0
auth_info = request.env['omniauth.auth']
# the data that really identifies the user
session[:userinfo] = auth_info['extra']['raw_info']
redirect_to '/private/index'
end
Use a breakpoint before the redirect to dig into the hash and experiment.
We usually want the email address too. To get it, you can update the scope:
scope: 'openid profile email'
After reloading the application server and going through the login steps, you will need to give the application access to additional information.
We can now do something like this in the callback
action:
def callback
# all the data sent by auth0
auth_info = request.env['omniauth.auth']
# the data that really identify the user
session[:userinfo] = auth_info['extra']['raw_info']
user = User.find_by(email: session[:userinfo]['email'] || User.new
if user.new_record?
user.name = session[:userinfo]['name']
user.email = session[:userinfo]['email']
end
user.save
redirect_to '/private/index'
end
This will ensure we have a local, up-to-date profile for the user.
A Word On Sessions
A session is a special hash in a Ruby on Rails application. It has limited storage that is accessible from the controllers and views and unique to each visitor.
We can "open" and "close" sessions. If we need a specific dataset, we can decide that a session is open. If it has no data, then it's closed.
Remember the following lines in our Auth0 controller:
# in the callback method
session[:userinfo] = auth_info['extra']['raw_info']
# in the logout method
reset_session
The first one writes the value in the raw_info
key of the hash to the session hash, while the second one just clears up the session hash.
We can then define the following helper method in the ApplicationHelper
:
# get the user
def current_user
User.find_by(email: session[:userinfo]['email']) if session[:userinfo]
end
A Security Concern
Now let's use this as a concern for our controllers. The idea is to define a controller that requires users to be logged in to access it, such as our private_controller
.
We can write the following concern file:
# ./app/controllers/concerns/secured.rb
module Secured
extend ActiveSupport::Concern
included do
before_action :logged_in?
end
def logged_in?
redirect_to '/' unless session[:userinfo].present?
end
end
We can then use it in our controllers like so:
class PrivateController < ApplicationController
include Secured
def index
end
end
If a visitor isn't logged in, but then tries to open up the /private/index
page, they will automatically be redirected to the site's root.
Integrating with Other Providers
You can rely on multiple providers through Auth0. However, Google is defined as the default. For many companies, that's enough.
If you need more providers, head to the Authentication menu for the related tenant in Auth0, and then the Social submenu, where you can set up additional providers.
It's also worth noting that you can configure multi-factor authentication (MFA) with Auth0 too.
The process will remain the same except for those configuration steps. Our application will only return visitor data to prove that a visitor has been authenticated.
Opening Up Towards Authorization
Now we can authenticate users and send their information to our application through a callback. We have already seen how to get a user's email address out of a hash and find the relevant user in our database.
From there, we can define authorization policies.
Pundit is a great choice to define and use authorization policies, yet relies on checking a user's role.
Using a role
attribute is, in fact, the easiest method. You can fill it in when creating a user in the Rails console or your application's back-end interface, before a user's first login attempt.
Or you can get a list of admins' emails and match a user's email against the list when creating the user.
ADMINS = ['johndoe@example.com']
def find_or_create_user(email:, name:)
user = User.find_by(email: session[:userinfo]['email'] || User.new
if user.new_record?
user.name = session[:userinfo]['name']
user.email = session[:userinfo]['email']
end
if ADMINS.include?(user.email)
user.role = 'admin'
else
user.role = 'normal'
end
user.save
end
And that's it!
What We've Covered
In this article, we set up:
- A tenant and application in Auth0
- Auth0-related gems in a Ruby on Rails application
- Routes and views in the application
We then:
- Reviewed the concept of a session and saw how to use it
- Created base tooling to check if a user is logged in
- Added a controller concern to secure controllers behind a login requirement
- Saw how to match users to their roles
Auth0 and other authentication providers can help you integrate state-of-the-art authentication in your Ruby on Rails application without writing much code. It's often a much better option than relying on your own implementation of the authentication layer or even using gems like Devise.
Happy coding!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Top comments (0)