Tutorial showing how to implement multi-tenant single sign-on (SSO) using Ruby on Rails, Devise, and SAML. Works with identity providers like Okta, Google, Azure, etc.
In this blog post, I want to describe how we implemented multi-tenant SSO at PagerTree to work with any SAML2 identity provider (Okta, Google, Azure, etc.).
STOP HERE - This is not a Copy Pasta™ blog post. Some things are very specific to the PagerTree implementation. You'll need to adapt the code to work for your project. This post is to help do most of the heavy lifting.
Stack Setup and Assumptions
This blog post will make a lot of assumptions about its implementation (it's a highly niche implementation).
This implementation uses the emailAddress attribute of SAML as the primary identifier for Users.
Checkout our SSO docs on how this looks in practice.
We've snipped a lot of PagerTree specific code for the purposes of brevity and staying focused.
SSO Keywords and Jargon
Some of the most confusing things in SSO implementation is that there is no "standard" naming convention. I have seen many aliases and synonyms all over the web.
saml - (Security Assertion Markup Language) is an XML-based standard for exchanging authentication and authorization data between parties, enabling Single Sign-On (SSO) functionality.
SSO Workflow Overview
If you are not familiar with SSO that's ok, I am going to go over the basic ideas (a full explanation is outside the scope of this article).
If you've ever logged in to an app using your Microsoft, Google, or work account, it likely used SAML to exchange information about your authentication. The IdP is responsible for the authentication of users (aka verifying users are who they say they are).
The basic workflow looks like this:
with The user comes to the SP application (aka your application).
The user provides the SP application with the authentication email (usually their work email).
The SP looks up the user and an IdP configuration this user is associated with. The user is then redirected to the IdP (idp_sso_service_url) with an authentication request in the format of an AuthNRequest.
At this point, the user either must provide valid credentials to the IdP. Once valid credentials are provided, and the the IdP confirms the user should have access to the SP application, the user is redirected by to the SP application at the assertion_consumer_service_url.
The SP is then responsible for granting access to the application based on the trusted response.
Two Entry Points
SP initiated - When a user comes to your app and clicks "Login using SSO" providing you their email address. This is probably the most common workflow and was described above.
IdP initiated - When a user logs in via their "app portal" from the IdP. Not very common, never have used it myself, but we need to support it. It doesn't change the code, but I am including it here for completeness.
Code
Migration
We need to add a model to hold each tenant's SSO configuration(s). I will briefly explain what each property is:
account_id - The tenant this belongs to.
meta - Free form hash where we can store any future data.
sp_entity_id - The unique identifier for this configuration.
name - A user friendly name so they can remember this configuration (ex: "Okta Config", "Okta Dev Config")
vendor - Enum identifier the IdP vendor. When debugging with customers why their configuration doesn't work, it's helpful to know the vendor (some vendors do some wonky stuff).
metadata_url - The URL to the IdP's metadata XML.
metadata_xml - The raw metadata XML (some vendors don't provide a metadata URL). The user should be able to copy and paste it into our app.
settings - A JSON representation of the parsed XML.
assertion_response_options - A hash of configurable options (per tenant) that we can pass into the Ruby SAML library.
Our IdPConfig model will hold a SSO configuration. Each account can have many IdPConfigs, but there will only ever be 0 or 1 active IdPConfigs for an account at a time.
A couple of important notes:
Line 78 - We use SecureRandom.hex and not a UUID. Azure does not like dashes in the sp_entity_id; a hex key will work across all known providers.
Line 95 - We use OneLogin::RubySaml::IdpMetadataParser to parse the XML provided by the user or the IdP's metadata_url.
app/models/idp_config.rb
# == Schema Information
#
# Table name: idp_configs
#
# id :binary not null, primary key
# assertion_response_options :jsonb not null
# discarded_at :datetime
# meta :jsonb not null
# metadata_url :string
# metadata_xml :string
# name :string not null
# settings :jsonb not null
# settings_cached_until :datetime
# vendor :integer default("saml2"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :binary not null
# prefix_id :string not null
# sp_entity_id :string not null
# tiny_id :integer not null
#
# Indexes
#
# index_idp_configs_on_account_id (account_id)
# index_idp_configs_on_account_id_and_tiny_id (account_id,tiny_id) UNIQUE
# index_idp_configs_on_discarded_at (discarded_at)
# index_idp_configs_on_prefix_id (prefix_id) UNIQUE
# index_idp_configs_on_sp_entity_id (sp_entity_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class IdpConfig < ApplicationRecord
include Discardable
include PrefixIdable
include Publishable
include Searchable
include TinyIdable
META_KEYS = [:v]
store_accessor :meta, *META_KEYS
# these are the options we can send into the Ruby SAML library
store_accessor :assertion_response_options, [:skip_authnstatement, :skip_conditions, :skip_subject_confirmation, :skip_recipient_check, :skip_audience]
acts_as_tenant :account
enum vendor: {saml2: 0, saml1: 1, adfs: 2, azure_ad: 3, google: 4, okta: 5, one_login: 6, ping_identity: 7}, _prefix: true
has_one :sso_account, class_name: "Account", foreign_key: "sso_config_id", dependent: :nullify
before_validation :callback_clear_settings, on: [:update], if: -> { metadata_xml_changed? || metadata_url_changed? }
attribute :skip_validate_subscription, :boolean, default: false
validates :name, presence: true
validates :vendor, presence: true
validates :sp_entity_id, presence: true, uniqueness: true
validates :metadata_url, url: {allow_blank: true, no_local: true}
validate :validate_metadata
validate :validate_subscription, on: :create, unless: :skip_validate_subscription?
after_discard do
deactivate! if active?
end
pg_search_scope :pg_search_default, against: :name
def prefix_id_prefix
"idp"
end
after_initialize do
self.v ||= 4
self.settings ||= {}
self.assertion_response_options ||= {}
self.sp_entity_id ||= SecureRandom.hex
end
def active?
account.sso_config_id == id
end
def activate!
account.sso_config_id = id
account.save!
end
def deactivate!
account.sso_config_id = nil
account.save!
end
def idp_metadata_parser
OneLogin::RubySaml::IdpMetadataParser.new
end
def parse_metadata
saml_settings = {}
if metadata_xml.present?
saml_settings = idp_metadata_parser.parse_to_hash(metadata_xml)
elsif metadata_url.present?
saml_settings = idp_metadata_parser.parse_remote_to_hash(metadata_url)
end
saml_settings
rescue
{}
end
def settings
# update the settings cache
if persisted? && (self[:settings].blank? || settings_cached_until.nil? || Time.current >= settings_cached_until)
self.settings = parse_metadata
self.settings_cached_until = Time.current + 1.day
save
end
super
end
def callback_clear_settings
self.settings = {}
self.settings_cached_until = nil
end
def assertion_consumer_service_url
Rails.application.routes.url_helpers.saml_callback_url(sp_entity_id: sp_entity_id)
end
def saml_metadata_url
Rails.application.routes.url_helpers.saml_metadata_url(sp_entity_id: sp_entity_id, format: :xml)
end
def saml_slo_url
Rails.application.routes.url_helpers.saml_logout_url(sp_entity_id: sp_entity_id)
end
def validate_metadata
errors.add(:base, "metadata_url OR metadata_xml (not both) is required") unless metadata_url.present? ^ metadata_xml.present? # exclusive or
errors.add(:base, "unable to parse metadata") if parse_metadata.blank?
end
def validate_subscription
# gotta make sure we are on https://sso.tax/ (its good for SEO)
errors.add(:base, I18n.t("consider_upgrading_for_create", model: I18n.t("plural.idp_config", count: 2))) unless account.subscription_feature_sso?
end
end
Routes
The important paths are as follows:
/sso - Where the user comes in the SP initiated workflow. We ask them for their email here.
/saml_callback - Alias for /public/saml/consume (see below). We had to support some legacy URLs when upgrading to v4.
/public/saml/consume - Where the IdP redirects the user to after they have provided their credentials to the IdP. This is the assertion_consumer_url. The payload of the request will be the assertion of who the user is.
/public/saml/metadata - A convenience endpoint for users to get information in XML format about the SP. IdP's sometimes will ask for this. Its a programmatic way for the SP to provide the IdP with details like the assertion_consumer_service_url
/public/saml/slo - The IdP will make a request here if the user is logged out. This is known as single logout. We need to destroy the users session when this URL is called.
config/routes.rb
devise_for :users, path: "",
controllers: {
registrations: "users/registrations",
sessions: "users/sessions",
},
path_names: {
sign_in: "login", sign_out: "logout", sign_up: "signup",
password: "forgot-password"
}
# opt-in saml_authenticatable
devise_scope :user do
scope "" do
match :sso, controller: "users/sessions", via: [:get, :post, :patch]
match :saml_callback, path: "public/saml/callback", controller: "users/sessions", via: [:get, :post]
match :consume, path: "public/saml/consume", controller: "users/sessions", action: "saml_callback", via: [:get, :post], as: "saml_consume" # legacy route
get :saml_metadata, path: "public/saml/metadata", controller: "users/sessions"
match :saml_logout, path: "public/saml/slo", controller: "users/sessions", via: [:get, :post]
end
end
Sessions Controller
You'll need to read through the sessions controller, but I will give a brief summary:
Line 82 - def saml_callback - Process the IdP response. This is the assertion_consumer_service_url.
Line 91 - if !user - Create a user if they don't exist in our database but were authenticated by the trusted IdP. This can occur when a SSO administrator adds access to your application and it's the users first time to login to your app.
Line 118 - def saml_metadata - The convenience method providing metadata that describes the SP configuration.
Line 126 - def saml_logout - Process the IdP initiated single logout request.
Line 164 - def verify_can_username_password - SSO users should be forced to use SSO
app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
skip_before_action :verify_authenticity_token, only: [:saml_callback, :saml_logout, :consume]
before_action :set_idp_config, only: [:saml_callback, :saml_metadata, :saml_logout]
# override the destroy method, and do special single logout stuff if they have it configured
def destroy
idp_config = current_account.sso_config
user = current_user
super do
if idp_config.present?
saml_sp_logout_request(idp_config, user)
end
end
end
def respond_to_on_destroy
# if we are doing single logout (SLO) don't redirect,
# the saml_sp_logout_request function handles the redirect
super unless session[:transaction_id]
end
# Handle the logic around the email input form and redirecting to their IdP
def sso
email = params[:email]&.downcase
if email
user = User.find_by_email(email)
if user
sso_accounts = user.accounts.sso_enabled.order(name: :asc)
sso_accounts = sso_accounts.where(id: params[:account_id]) if params[:account_id]
if sso_accounts.size == 0
# set an error message saying they have no accounts configured w/ sso
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t(".sso_account_not_found"), allow_other_host: true
elsif sso_accounts.size == 1
# set the current tentant
account = sso_accounts.first
if account.subscription_feature_value(:sso) != true
flash.now[:alert] = t(".please_upgrade")
elsif account.sso_config.present?
request = OneLogin::RubySaml::Authrequest.new
settings = get_saml_settings(account.sso_config)
# Special settings for Microsoft products
# Azure AD will produce the following error if the subject is provided:
# AADSTS900236: The SAML authentication request property 'Subject' is not supported and must not be set.
unless settings.idp_entity_id&.starts_with?("https://sts.windows.net/")
settings.name_identifier_value_requested = email
settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
end
redirect_to(request.create(settings), allow_other_host: true)
else
flash.now[:alert] = t(".primary_idp_not_set")
end
else
# ask them which account to sign into
@email = email
@accounts = sso_accounts
flash.now[:alert] = t(".select_account")
end
else
flash.now[:alert] = t(".user_not_found")
end
end
end
def assertion_response_options
idp_options = {}
# we can put any nasty vendor work arounds here
idp_options = {skip_subject_confirmation: true} if @idp_config.vendor_one_login?
{
allowed_clock_drift: Rails.env.test? ? 100.years : 5.seconds
}.merge(idp_options).merge(@idp_config.assertion_response_options.symbolize_keys)
end
def saml_callback
return redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t("users.sessions.saml_callback.not_found"), allow_other_host: true unless @idp_config
return redirect_to new_user_session_url(host: Rails.application.routes.default_url_options[:host]), alert: t("consider_upgrading") unless @idp_config.account.subscription_feature_sso?
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: get_saml_settings(@idp_config), **assertion_response_options)
collect_errors = true
if response.is_valid?(collect_errors)
email = response.name_id&.downcase
user = User.find_by_email(email)
if !user
# if the user was not in the database, this was likely a IdP initiated
# create an account for them, and give them a temp password
user = User.new(
email: email,
password: ::Devise.friendly_token[0, 20],
terms_of_service: true,
name: email
)
user.set_new_user_default_preferences
user.skip_confirmation!
user.account_users.new(account: @idp_config.account)
user.save!
end
# remember the users to they don't have to login again
user.remember_me = true
sign_in(user)
session[:account_id] = @idp_config.account_id
session[:sso] = true
redirect_to root_url(host: Rails.application.routes.default_url_options[:host]), allow_other_host: true # force them back to the main site
else
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t("users.sessions.saml_callback.invalid_response", message: response.errors.join(", ")), allow_other_host: true
end
end
# See https://github.com/onelogin/ruby-saml#service-provider-metadata
def saml_metadata
raise ActionController::RoutingError.new(t(".not_found")) unless @idp_config
settings = get_saml_settings(@idp_config)
meta = OneLogin::RubySaml::Metadata.new
render xml: meta.generate(settings), content_type: "application/samlmetadata+xml"
end
# Trigger SP and IdP initiated Logout requests
def saml_logout
if params[:SAMLRequest]
# If we're given a logout request, handle it in the IdP logout initiated method
saml_idp_logout_request
elsif params[:SAMLResponse]
# We've been given a response back from the IdP, process it
saml_process_logout_response
end
end
# 2FA code snipped for example brevity
def find_user
if sign_in_params[:email].present?
resource_class.find_by_email(sign_in_params[:email].downcase)
end
end
def get_saml_settings(idp_config)
settings = OneLogin::RubySaml::Settings.new(idp_config.settings)
# From the gem docs - "The use of settings.issuer is deprecated in favour of settings.sp_entity_id since version 1.11.0"
# Sett IdpConfig model for the branching of the logic between v3 and v4
settings.assertion_consumer_service_url = idp_config.assertion_consumer_service_url
settings.sp_entity_id = idp_config.sp_entity_id
settings
end
def set_idp_config
sp_entity_id = params[:sp_entity_id]
if sp_entity_id.present?
Rails.logger.debug "SSO set_idp_config - sp_entity_id: #{sp_entity_id}"
@idp_config = IdpConfig.find_by(sp_entity_id: sp_entity_id)
end
end
def verify_can_username_password
user = find_user
return unless user&.requires_sso_signin?
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host], email: sign_in_params[:email]), alert: "Please log in using SSO", allow_other_host: true
end
# Method to handle IdP initiated logouts
def saml_idp_logout_request
settings = get_saml_settings(@idp_config)
logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest])
if !logout_request.is_valid?
error_message = "IdP initiated LogoutRequest was not valid!"
Rails.logger.error error_message
return render inline: error_message
end
email = logout_request.name_id.downcase
Rails.logger.debug "IdP initiated saml_idp_logout_request for #{email}"
# Actually log out this session
user = User.find_by_email(email)
sign_out(user) if user
# Generate a response to the IdP.
logout_request_id = logout_request.id
logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request_id, nil, RelayState: params[:RelayState])
redirect_to logout_response, allow_other_host: true
end
# Create a SP initiated SLO
def saml_sp_logout_request(idp_config, user)
# LogoutRequest accepts plain browser requests w/o paramters
settings = get_saml_settings(idp_config)
if settings.idp_slo_service_url.present?
email = user.email
logout_request = OneLogin::RubySaml::Logoutrequest.new
Rails.logger.debug "New SP SLO for userid '#{email}' transactionid '#{logout_request.uuid}'"
settings.name_identifier_value = email
# Save the transaction_id to compare it with the response we get back
session[:transaction_id] = logout_request.uuid
relay_state = saml_logout_url
redirect_to(logout_request.create(settings, RelayState: relay_state), allow_other_host: true)
end
end
def saml_process_logout_response
return redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t(".not_found"), allow_other_host: true unless @idp_config
settings = get_saml_settings(@idp_config)
if session.has_key?(:transaction_id)
logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings, matches_request_id: session[:transaction_id])
session.delete(:transaction_id)
else
logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings)
end
# Validate the SAML Logout Response, but we don't do anything besides basically log it (we can't do anything about it)
collect_errors = true
if !logout_response.validate(collect_errors)
Rails.logger.error "The SAML logout response is invalid: #{logout_response.errors.join(", ")}"
end
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), allow_other_host: true
end
end
Gotchas
Switching Accounts
In PagerTree, a user can belong to many accounts. However, we don't want users to be able to have a personal account and login via username and password and then switch to an SSO enabled account. For SSO enabled accounts, a user should always be required to authenticate via SSO.
So in /app/controllers/accounts_controller.rb we have something like this:
def switch
# ... snip ...
if @account.sso_config_id.present?
# log them out and make them auth against SSO
email = current_user.email
sign_out(current_user)
redirect_to sso_url(email: email, account_id: @account.id, script_name: nil), **options
end
# ... snip ...
end
Feedback
The Multi-Tenant SSO setup is a fairly advanced topic. Having done this several times before, I am sure I missed some things and could likely make other things clearer. If you have any constructive feedback you can reach out to me on Twitter. I can't address every comment, but with your input I will try my best to update this content to make it even clearer for others in the community.