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 Ory Polis. The following sections focus more on the SSO authentication side. Ory Polis
Deploy Ory Polis
Ory Polis The Ory Poliss to deploy the Ory Polis service. Follow the deployment docs to install and configure the Ory Polis. Ory Polis
Setup Ory Polis Integration
We will dive into Ory Polis 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 Ory Polis
Add a custom sorcery provider for Ory Polis. 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
Ory Polis. Ory Polis 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 Ory Polis 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 Ory Polis 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 Ory Polis sends back the authorization code
config.boxyhqsso.callback_url = 'http://localhost:3366/oauth/callback'
# This will be passed to Ory Polis token endpoint as part of credentials
config.boxyhqsso.secret = ENV['CLIENT_SECRET_VERIFIER']
# Takes care of converting the user info from the provider (Ory Polis) 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 Ory Polis 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 Ory Polis SSO service
post 'sso', to: 'sorcery#oauth'
# logout the user
delete 'logout' => 'logins#destroy', as: :logout
# handles the redirect back from Ory Polis 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 Ory Polis 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 Ory Polis
Add a custom omniauth strategy for Ory Polis. We will name it Boxyhqsso
. By inheriting from OmniAuth::Strategies::OAuth2
, we
can wire up the OAuth 2.0 flow with Ory Polis. Ory Polis 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 Ory Polis
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 Ory Polis 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 Ory Polis 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