Skip to main content

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
Generate migration scripts for sorcery
    bin/rails g sorcery:install external --only-submodules
Run migration scripts
    bin/rake db:migrate
Generate the Authentication model
    bin/rails generate model Authentication --migration=false
Modify the user schema
    # 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.

info

By including the file in the app/lib folder, rails will autoload the provider class.

app/lib/sorcery/providers/boxyhqsso.rb
    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.

config/initializers/sorcery.rb
    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.

info

The login flow is initiated by posting to /sso

config/routes.rb
    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

With OmniAuth

info

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.

app/lib/omniauth/strategies/boxyhqsso.rb
      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.

config/initializers/omniauth.rb
    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.

info

The login flow is initiated by posting to /auth/boxyhqsso which is handled by omniauth in the rack middleware pipeline.

config/routes.rb
    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