Add SAML SSO to Ruby on Rails App
This guide assumes that you have a Ruby on Rails app and want to enable SAML Single Sign-On authentication for your enterprise customers. By the end of this guide, you'll have an app that allows you to authenticate the users using SAML Single Sign-On.
Visit the GitHub repository to see the source code for the Ruby on Rails SAML SSO integration.
Integrating SAML SSO into an app involves the following steps.
- Configure SAML Single Sign-On
- Authenticate with SAML Single Sign-On
Configure Enterprise SSO on Rails
This step allows your tenants to configure SAML connections for their users. Read the following guides to understand more about this.
Authenticate with SAML Single Sign-On
Once you add a SAML connection, the app can use this SAML connection to initiate the SSO authentication flow using SAML Jackson. The following sections focus more on the SSO authentication side.
Deploy SAML Jackson
The first step is to deploy the SAML Jackson service. Follow the deployment docs to install and configure the SAML Jackson.
Setup SAML Jackson Integration
We will dive into Jackson integration with two popular authentication libraries:
With Sorcery
First, we need to install and configure sorcery.
Install Dependencies
Install the sorcery
gem using
bundle add sorcery
Configure the database
bin/rails g sorcery:install external --only-submodules
bin/rake db:migrate
bin/rails generate model Authentication --migration=false
# remove the unused columns from the user table, we won't need the password field as the login is external
bin/rails generate migration RemoveColumnsFromUsers crypted_password:string salt:string
# add the new columns
bin/rails generate migration AddColumnsToUsers firstName:string lastName:string uid:string
# run the migrations
bin/rake db:migrate
Add a custom provider for Jackson
Add a custom sorcery provider for Jackson. We will name it Boxyhqsso
.
We rely on the Protocols::Oauth2
mixin from the sorcery package. In a nutshell, here we are wiring up the OAuth 2.0 flow with Jackson. Jackson will redirect to the configured IdP connection based on the tenant/product.
By including the file in the app/lib
folder, rails will autoload the provider class.
module Sorcery
module Providers
# This class adds support for OAuth2.0 SSO flow with Jackson service.
#
# config.boxyhqsso.site = <http://localhost:5225>
# config.boxyhqsso.key = <key>
# config.boxyhqsso.secret = <secret>
# ...
#
class Boxyhqsso < Base
include Protocols::Oauth2
attr_reader :parse
attr_accessor :auth_url, :token_url, :user_info_path
def initialize
super
@site = ENV['JACKSON_URL']
@auth_url = '/api/oauth/authorize'
@token_url = '/api/oauth/token'
@user_info_path = '/api/oauth/userinfo'
@parse = :json
# @state = SecureRandom.hex(16)
end
def get_user_hash(access_token)
response = access_token.get(user_info_path)
body = JSON.parse(response.body)
auth_hash(access_token).tap do |h|
h[:user_info] = body
h[:uid] = body['id']
end
end
# calculates and returns the url to which the user should be redirected,
# to get authenticated at the external provider's site.
def login_url(params, _session)
add_param(authorize_url(authorize_url: auth_url),
[
{ name: 'tenant', value: params[:tenant] },
{ name: 'product', value: params[:product] }
])
end
# tries to login the user from access token
def process_callback(params, _session)
args = {}.tap do |a|
a[:code] = params[:code] if params[:code]
end
get_access_token(args, token_url: token_url, token_method: :post, auth_scheme: :request_body)
end
def add_param(url, query_params)
uri = URI(url)
qp = URI.decode_www_form(uri.query || [])
query_params.each do |param|
qp << [param[:name], param[:value]]
end
uri.query = URI.encode_www_form(qp)
uri.to_s
end
end
end
end
Configure the custom sorcery provider
Add an initializer file to configure the sorcery module. Here we tell sorcery to load the :external
submodule and also add boxyhqsso
custom provider from the previous step to the external_providers
list. Also, see the inline comments for boxyhqsso
provider settings.
Rails.application.config.sorcery.submodules = [:external]
# Here you can configure each submodule's features.
Rails.application.config.sorcery.configure do |config|
config.external_providers = [:boxyhqsso]
# URL of Jackson service
config.boxyhqsso.site = ENV['JACKSON_URL']
# This translates to client_id in OAuth 2.0. Setting it to dummy will allow us to use `tenant` and product` params instead
config.boxyhqsso.key = 'dummy'
# The url of the rails app to which Jackson sends back the authorization code
config.boxyhqsso.callback_url = 'http://localhost:3366/oauth/callback'
# This will be passed to Jackson token endpoint as part of credentials
config.boxyhqsso.secret = ENV['CLIENT_SECRET_VERIFIER']
# Takes care of converting the user info from the provider (Jackson) into the attributes of the User.
config.boxyhqsso.user_info_mapping = { email: 'email', uid: 'id', firstName: 'firstName', lastName: 'lastName'}
# --- user config ---
config.user_config do |user|
# -- external --
user.authentications_class = Authentication
end
# This line must come after the 'user config' block.
# Define which model authenticates with sorcery.
config.user_class = User
end
Routes and Controllers
Finally, we need to add the routes and controller files that initiate the login flow and handle the callback from the Jackson service.
The login flow is initiated by posting to /sso
- Routes
- Controllers
Rails.application.routes.draw do
...
# Renders the login page
get 'sso', to: 'logins#index', as: :login
# Initiates the OAuth 2.0 redirect to Jackson SSO service
post 'sso', to: 'sorcery#oauth'
# logout the user
delete 'logout' => 'logins#destroy', as: :logout
# handles the redirect back from Jackson SSO service, exchanges code with access_token and then fetches userprofile. Sorcery creates the user if not present in database, else return the one in the db.
resource :oauth do
get :callback, to: 'sorcery#callback', on: :collection
end
# Show profile data
get 'profile', to: 'profiles#index', as: :profile
...
end
- SorceryController
- LoginsController
- ProfilesController
The oauth
action initiates the OAuth 2.0 flow to Jackson SSO service. In the callback
action, sorcery exchanges the code for access_token and user profile. If a user exists in the database, then the value of @current_user
is loaded from the database. Else a new user is created in the database and returned.
class SorceryController < ApplicationController
skip_before_action :require_login, raise: false
def oauth
login_at('boxyhqsso', state: SecureRandom.hex(16))
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def callback
provider = 'boxyhqsso'
if @user = login_from(provider)
redirect_to profile_path, notice: "Logged in from #{provider.titleize}!"
else
begin
@user = create_from(provider)
reset_session # protect from session fixation attack
auto_login(@user)
redirect_to profile_path, notice: "Logged in from #{provider.titleize}!"
rescue
redirect_to root_path, alert: "Failed to login from #{provider.titleize}!"
end
end
rescue ::OAuth2::Error => e
Rails.logger.error e
Rails.logger.error e.code
Rails.logger.error e.description
Rails.logger.error e.message
Rails.logger.error e.backtrace
end
end
class LoginsController < ApplicationController
skip_before_action :require_login
def index; # render login view
end
def destroy
logout
redirect_to(root_path, notice: 'Logged out!')
end
end
class ProfilesController < ApplicationController
def index; end # display profile information
end
With OmniAuth
Unlike sorcery, omniauth does not automatically associate with a User model nor persist the user in the database.
First, we need to install and configure omniauth.
Install Dependencies
bin/bundle add omniauth
bin/bundle add omniauth-rails_csrf_protection # Used to protect against CSRF vulnerability
bin/bundle add omniauth-oauth2 # generic OAuth2 strategy for OmniAuth that we will inherit from
Add a custom strategy for Jackson
Add a custom omniauth strategy for Jackson. We will name it Boxyhqsso
.
By inheriting from OmniAuth::Strategies::OAuth2
, we can wire up the OAuth 2.0 flow with Jackson. Jackson will redirect to the configured IdP connection based on the tenant/product.
module OmniAuth
module Strategies
class Boxyhqsso < OmniAuth::Strategies::OAuth2
# strategy name
option :name, "boxyhqsso"
args %i[
client_id
client_secret
domain
]
# Setup client URLs used during authentication
def client
options.client_options.site = domain_url
options.client_options.authorize_url = '/api/oauth/authorize'
options.client_options.token_url = '/api/oauth/token'
options.client_options.userinfo_url = '/api/oauth/userinfo'
options.client_options.auth_scheme = :request_body
options.token_params = { :redirect_uri => full_host + '/auth/boxyhqsso/callback' }
super
end
# These are called after authentication has succeeded. If
# possible, you should try to set the UID without making
# additional calls (if the user id is returned with the token
# or as a URI parameter). This may not be possible with all
# providers.
uid{ raw_info['id'] }
# Define the parameters used for the /authorize endpoint
def authorize_params
params = super
%w[connection connection_scope prompt screen_hint login_hint organization invitation ui_locales tenant product].each do |key|
params[key] = request.params[key] if request.params.key?(key)
end
# Generate nonce
params[:nonce] = SecureRandom.hex
# Store authorize params in the session for token verification
session['authorize_params'] = params.to_hash
params
end
extra do
{
'raw_info' => raw_info
}
end
# Declarative override for the request phase of authentication
def request_phase
if no_client_id?
# Do we have a client_id for this Application?
fail!(:missing_client_id)
elsif no_client_secret?
# Do we have a client_secret for this Application?
fail!(:missing_client_secret)
elsif no_domain?
# Do we have a domain for this Application?
fail!(:missing_domain)
else
# All checks pass, run the Oauth2 request_phase method.
super
end
end
def raw_info
userinfo_url = options.client_options.userinfo_url
@raw_info ||= access_token.get(userinfo_url).parsed
end
# Check if the options include a client_id
def no_client_id?
['', nil].include?(options.client_id)
end
# Check if the options include a client_secret
def no_client_secret?
['', nil].include?(options.client_secret)
end
# Check if the options include a domain
def no_domain?
['', nil].include?(options.domain)
end
# Normalize a domain to a URL.
def domain_url
domain_url = URI(options.domain)
domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil?
domain_url.to_s
end
end
end
end
Configure the custom omniauth provider
Add an initializer file to insert omniauth into the rack middleware pipeline. OmniAuth::Builder
allows us to load multiple strategies.
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:boxyhqsso,
'dummy',
ENV['CLIENT_SECRET_VERIFIER'],
ENV['JACKSON_URL'],
callback_path: '/auth/boxyhqsso/callback',
authorize_params: {
scope: 'openid'
}
)
end
Routes and Controllers
Finally, we need to add the routes and controller files that initiate the login flow and handle the callback from the Jackson service. We also use a controller concern
to control access to protected routes such as the profile page.
The login flow is initiated by posting to /auth/boxyhqsso
which is handled by omniauth in the rack middleware pipeline.
- Routes
- Controllers
- Controller concern
Rails.application.routes.draw do
# Renders the login page
get 'sso', to: 'logins#index', as: :login
# handles the redirect back from Jackson SSO service, exchanges code with access_token and then fetches userprofile.
get 'auth/boxyhqsso/callback', to: 'omniauth#callback'
# Show profile data
get 'omniauth/profile', to: 'omniauth_profiles#show', as: :omniauth_profile
# logout the user
delete 'omniauth/logout' => 'omniauth#logout', as: :omniauth_logout
end
- OmniauthController
- OmniauthProfilesController
After omniauth handles the callback from Jackson SSO service, it sets an authentication hash (omniauth.auth
) on the rack environment of a request to /auth/boxyhqsso/callback
. This contains the information about the logged-in user. We then set this value in the session which can then be displayed on the profile page.
class OmniauthController < ApplicationController
skip_before_action :require_login, raise: false
def callback
user_info = request.env['omniauth.auth']
session[:userinfo] = user_info['extra']['raw_info']
redirect_to omniauth_profile_path, notice: "Logged in using omniauth!"
end
def logout
reset_session
redirect_to root_path, notice: "Logged out from Omniauth!"
end
end
Here we set the instance variable @user
from the session. This can then be referenced in the profile view. Also by using the concern OmniauthSecured
, we ensure that the profile view is rendered only if a user is logged in, else we redirect to the login page.
class OmniauthProfilesController < ApplicationController
skip_before_action :require_login, raise: false
include OmniauthSecured
def show
@user = session[:userinfo]
end
end
module OmniauthSecured
extend ActiveSupport::Concern
included do
before_action :logged_in_using_omniauth?
end
def logged_in_using_omniauth?
redirect_to login_path, notice: "⚠️ Please login using omniauth" unless session[:userinfo].present?
end
end