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)#classIdpConfig<ApplicationRecordincludeDiscardableincludePrefixIdableincludePublishableincludeSearchableincludeTinyIdableMETA_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: :namedefprefix_id_prefix"idp"end after_initialize do self.v ||=4 self.settings ||= {} self.assertion_response_options ||= {} self.sp_entity_id ||=SecureRandom.hexenddefactive? account.sso_config_id == idenddefactivate! account.sso_config_id = id account.save!enddefdeactivate! account.sso_config_id =nil account.save!enddefidp_metadata_parserOneLogin::RubySaml::IdpMetadataParser.newenddefparse_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_settingsrescue {}enddefsettings# update the settings cacheif 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 saveendsuperenddefcallback_clear_settings self.settings = {} self.settings_cached_until =nilenddefassertion_consumer_service_urlRails.application.routes.url_helpers.saml_callback_url(sp_entity_id: sp_entity_id)enddefsaml_metadata_urlRails.application.routes.url_helpers.saml_metadata_url(sp_entity_id: sp_entity_id, format: :xml)enddefsaml_slo_urlRails.application.routes.url_helpers.saml_logout_url(sp_entity_id: sp_entity_id)enddefvalidate_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?enddefvalidate_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?
endend
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.
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
classUsers::SessionsController<Devise::SessionsControllerincludeDevise::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 configureddefdestroy idp_config = current_account.sso_config user = current_usersuperdoif idp_config.present? saml_sp_logout_request(idp_config, user)endendenddefrespond_to_on_destroy# if we are doing single logout (SLO) don't redirect,# the saml_sp_logout_request function handles the redirectsuperunless session[:transaction_id]end# Handle the logic around the email input form and redirecting to their IdPdefsso email = params[:email]&.downcaseif 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.firstif 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")endelse# ask them which account to sign into @email = email @accounts = sso_accounts flash.now[:alert] = t(".select_account")endelse flash.now[:alert] = t(".user_not_found")endendenddefassertion_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)enddefsaml_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 =trueif 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
endend# See https://github.com/onelogin/ruby-saml#service-provider-metadatadefsaml_metadataraiseActionController::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 requestsdefsaml_logoutif params[:SAMLRequest]# If we're given a logout request, handle it in the IdP logout initiated method saml_idp_logout_requestelsif params[:SAMLResponse]# We've been given a response back from the IdP, process it saml_process_logout_responseendend# 2FA code snipped for example brevitydeffind_userif sign_in_params[:email].present? resource_class.find_by_email(sign_in_params[:email].downcase)endenddefget_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 settingsenddefset_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)endenddefverify_can_username_password user = find_userreturnunless 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 logoutsdefsaml_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_messagereturn render inline: error_messageend email = logout_request.name_id.downcaseRails.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: trueend# Create a SP initiated SLOdefsaml_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.newRails.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)endenddefsaml_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 =trueif!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: trueendend
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:
defswitch# ... 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),**optionsend# ... 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.