Links
💎

Multi-Tenant SSO using Devise

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.
Recently while scrolling on Twitter I saw this tweet by John Nunemaker.
Since we had implemented multi-tenant SSO at PagerTree, I thought I could help out. After all, I claimed it was only ~100 lines of code (turns out it is closer to 400). After sharing a raw gist, I realized a blog post would be more helpful to the community.
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).

Important Notes

  • 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.
  • idp - Identity Provider (IdP) - Your customer's authentication provider (ex: Okta, Google, Azure, etc.)
  • idp_entity_id - The unique tenant identifier in the IdP's database.
  • idp_sso_service_url - The URL your app needs to redirect the user to with the AuthNRequest. It will be at the IdP's domain.
  • sp - Service Provider (SP) - Your app, the one you are building (ex: PagerTree)
  • sp_entity_id - A unique tenant identifier in the SP's database.
  • assertion_consumer_service_url - The endpoint on the SP where the IdP should send the user after they have authenticated.
  • authnrequest - Programmatic authentication request.
  • slo - Single Logout
  • 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:
  1. 1.
    with The user comes to the SP application (aka your application).
  2. 2.
    The user provides the SP application with the authentication email (usually their work email).
    SSO Login
  3. 3.
    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.
  4. 4.
    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.
  5. 5.
    The SP is then responsible for granting access to the application based on the trusted response.

Two Entry Points

  1. 1.
    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.
  2. 2.
    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.
1
class CreateIdpConfigs < ActiveRecord::Migration[7.0]
2
def change
3
create_table :idp_configs, id: false do |t|
4
t.binary :id, limit: 16, primary_key: true
5
t.references :account, type: :binary, limit: 16, null: false, foreign_key: true
6
t.string :prefix_id, null: false
7
t.integer :tiny_id, null: false
8
9
t.jsonb :meta, null: false, default: {}
10
11
t.string :sp_entity_id, null: false
12
t.string :name, null: false
13
14
t.integer :vendor, null: false, default: 0
15
16
t.string :metadata_url, null: true, default: nil
17
t.string :metadata_xml, null: true, default: nil
18
19
t.jsonb :settings, null: false, default: {}
20
t.datetime :settings_cached_until, null: true, default: nil
21
t.jsonb :assertion_response_options, null: false, default: {}
22
23
t.datetime :discarded_at
24
25
t.timestamps
26
t.index :discarded_at
27
t.index [:account_id, :tiny_id], unique: true
28
t.index :prefix_id, unique: true
29
t.index :sp_entity_id, unique: true
30
end
31
32
add_column :accounts, :sso_config_id, :binary, limit: 16, null: true, foreign_key: {to_table: :idp_configs}
33
end
34
end
35

Model

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
1
# == Schema Information
2
#
3
# Table name: idp_configs
4
#
5
# id :binary not null, primary key
6
# assertion_response_options :jsonb not null
7
# discarded_at :datetime
8
# meta :jsonb not null
9
# metadata_url :string
10
# metadata_xml :string
11
# name :string not null
12
# settings :jsonb not null
13
# settings_cached_until :datetime
14
# vendor :integer default("saml2"), not null
15
# created_at :datetime not null
16
# updated_at :datetime not null
17
# account_id :binary not null
18
# prefix_id :string not null
19
# sp_entity_id :string not null
20
# tiny_id :integer not null
21
#
22
# Indexes
23
#
24
# index_idp_configs_on_account_id (account_id)
25
# index_idp_configs_on_account_id_and_tiny_id (account_id,tiny_id) UNIQUE
26
# index_idp_configs_on_discarded_at (discarded_at)
27
# index_idp_configs_on_prefix_id (prefix_id) UNIQUE
28
# index_idp_configs_on_sp_entity_id (sp_entity_id) UNIQUE
29
#
30
# Foreign Keys
31
#
32
# fk_rails_... (account_id => accounts.id)
33
#
34
class IdpConfig < ApplicationRecord
35
include Discardable
36
include PrefixIdable
37
include Publishable
38
include Searchable
39
include TinyIdable
40
41
META_KEYS = [:v]
42
store_accessor :meta, *META_KEYS
43
44
# these are the options we can send into the Ruby SAML library
45
store_accessor :assertion_response_options, [:skip_authnstatement, :skip_conditions, :skip_subject_confirmation, :skip_recipient_check, :skip_audience]
46
47
acts_as_tenant :account
48
49
enum vendor: {saml2: 0, saml1: 1, adfs: 2, azure_ad: 3, google: 4, okta: 5, one_login: 6, ping_identity: 7}, _prefix: true
50
51
has_one :sso_account, class_name: "Account", foreign_key: "sso_config_id", dependent: :nullify
52
53
before_validation :callback_clear_settings, on: [:update], if: -> { metadata_xml_changed? || metadata_url_changed? }
54
55
attribute :skip_validate_subscription, :boolean, default: false
56
57
validates :name, presence: true
58
validates :vendor, presence: true
59
validates :sp_entity_id, presence: true, uniqueness: true
60
validates :metadata_url, url: {allow_blank: true, no_local: true}
61
validate :validate_metadata
62
validate :validate_subscription, on: :create, unless: :skip_validate_subscription?
63
64
after_discard do
65
deactivate! if active?
66
end
67
68
pg_search_scope :pg_search_default, against: :name
69
70
def prefix_id_prefix
71
"idp"
72
end
73
74
after_initialize do
75
self.v ||= 4
76
self.settings ||= {}
77
self.assertion_response_options ||= {}
78
self.sp_entity_id ||= SecureRandom.hex
79
end
80
81
def active?
82
account.sso_config_id == id
83
end
84
85
def activate!
86
account.sso_config_id = id
87
account.save!
88
end
89
90
def deactivate!
91
account.sso_config_id = nil
92
account.save!
93
end
94
95
def idp_metadata_parser
96
OneLogin::RubySaml::IdpMetadataParser.new
97
end
98
99
def parse_metadata
100
saml_settings = {}
101
if metadata_xml.present?
102
saml_settings = idp_metadata_parser.parse_to_hash(metadata_xml)
103
elsif metadata_url.present?
104
saml_settings = idp_metadata_parser.parse_remote_to_hash(metadata_url)
105
end
106
saml_settings
107
rescue
108
{}
109
end
110
111
def settings
112
# update the settings cache
113
if persisted? && (self[:settings].blank? || settings_cached_until.nil? || Time.current >= settings_cached_until)
114
self.settings = parse_metadata
115
self.settings_cached_until = Time.current + 1.day
116
save
117
end
118
119
super
120
end
121
122
def callback_clear_settings
123
self.settings = {}
124
self.settings_cached_until = nil
125
end
126
127
def assertion_consumer_service_url
128
Rails.application.routes.url_helpers.saml_callback_url(sp_entity_id: sp_entity_id)
129
end
130
131
def saml_metadata_url
132
Rails.application.routes.url_helpers.saml_metadata_url(sp_entity_id: sp_entity_id, format: :xml)
133
end
134
135
def saml_slo_url
136
Rails.application.routes.url_helpers.saml_logout_url(sp_entity_id: sp_entity_id)
137
end
138
139
def validate_metadata
140
errors.add(:base, "metadata_url OR metadata_xml (not both) is required") unless metadata_url.present? ^ metadata_xml.present? # exclusive or
141
errors.add(:base, "unable to parse metadata") if parse_metadata.blank?
142
end
143
144
def validate_subscription
145
# gotta make sure we are on https://sso.tax/ (its good for SEO)
146
errors.add(:base, I18n.t("consider_upgrading_for_create", model: I18n.t("plural.idp_config", count: 2))) unless account.subscription_feature_sso?
147
end
148
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
1
devise_for :users, path: "",
2
controllers: {
3
registrations: "users/registrations",
4
sessions: "users/sessions",
5
},
6
path_names: {
7
sign_in: "login", sign_out: "logout", sign_up: "signup",
8
password: "forgot-password"
9
}
10
# opt-in saml_authenticatable
11
devise_scope :user do
12
scope "" do
13
match :sso, controller: "users/sessions", via: [:get, :post, :patch]
14
match :saml_callback, path: "public/saml/callback", controller: "users/sessions", via: [:get, :post]
15
match :consume, path: "public/saml/consume", controller: "users/sessions", action: "saml_callback", via: [:get, :post], as: "saml_consume" # legacy route
16
get :saml_metadata, path: "public/saml/metadata", controller: "users/sessions"
17
match :saml_logout, path: "public/saml/slo", controller: "users/sessions", via: [:get, :post]
18
end
19
end

Sessions Controller

You'll need to read through the sessions controller, but I will give a brief summary:
  • Line 6 - skip_before_action :verify_authenticity_token - On requests from the IdP, don't verify the authenticity token (a Rails security feature).
  • Line 7 - before_action :set_idp_config - Set the IdP Config for SSO methods.
  • Line 10 - def destroy - Override the Devise destroy method. Send the IdP a logout request if our user logsout from our app.
  • Line 27 - def sso - Render the SSO page to capture the users email.
  • Line 28 - user = User.find_by_email(email) - We use the emailAddress as the primary identifier between IdP and SP.
  • 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
1
class Users::SessionsController < Devise::SessionsController
2
include Devise::Controllers::Rememberable
3
4
skip_before_action :verify_authenticity_token, only: [:saml_callback, :saml_logout, :consume]
5
before_action :set_idp_config, only: [:saml_callback, :saml_metadata, :saml_logout]
6
7
# override the destroy method, and do special single logout stuff if they have it configured
8
def destroy
9
idp_config = current_account.sso_config
10
user = current_user
11
super do
12
if idp_config.present?
13
saml_sp_logout_request(idp_config, user)
14
end
15
end
16
end
17
18
def respond_to_on_destroy
19
# if we are doing single logout (SLO) don't redirect,
20
# the saml_sp_logout_request function handles the redirect
21
super unless session[:transaction_id]
22
end
23
24
# Handle the logic around the email input form and redirecting to their IdP
25
def sso
26
email = params[:email]&.downcase
27
if email
28
user = User.find_by_email(email)
29
if user
30
sso_accounts = user.accounts.sso_enabled.order(name: :asc)
31
sso_accounts = sso_accounts.where(id: params[:account_id]) if params[:account_id]
32
33
if sso_accounts.size == 0
34
# set an error message saying they have no accounts configured w/ sso
35
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t(".sso_account_not_found"), allow_other_host: true
36
elsif sso_accounts.size == 1
37
# set the current tentant
38
account = sso_accounts.first
39
if account.subscription_feature_value(:sso) != true
40
flash.now[:alert] = t(".please_upgrade")
41
elsif account.sso_config.present?
42
request = OneLogin::RubySaml::Authrequest.new
43
settings = get_saml_settings(account.sso_config)
44
45
# Special settings for Microsoft products
46
# Azure AD will produce the following error if the subject is provided:
47
# AADSTS900236: The SAML authentication request property 'Subject' is not supported and must not be set.
48
unless settings.idp_entity_id&.starts_with?("https://sts.windows.net/")
49
settings.name_identifier_value_requested = email
50
settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
51
end
52
53
redirect_to(request.create(settings), allow_other_host: true)
54
else
55
flash.now[:alert] = t(".primary_idp_not_set")
56
end
57
else
58
# ask them which account to sign into
59
@email = email
60
@accounts = sso_accounts
61
flash.now[:alert] = t(".select_account")
62
end
63
else
64
flash.now[:alert] = t(".user_not_found")
65
end
66
end
67
end
68
69
def assertion_response_options
70
idp_options = {}
71
72
# we can put any nasty vendor work arounds here
73
idp_options = {skip_subject_confirmation: true} if @idp_config.vendor_one_login?
74
75
{
76
allowed_clock_drift: Rails.env.test? ? 100.years : 5.seconds
77
}.merge(idp_options).merge(@idp_config.assertion_response_options.symbolize_keys)
78
end
79
80
def saml_callback
81
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
82
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?
83
84
response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: get_saml_settings(@idp_config), **assertion_response_options)
85
86
collect_errors = true
87
if response.is_valid?(collect_errors)
88
email = response.name_id&.downcase
89
user = User.find_by_email(email)
90
91
if !user
92
# if the user was not in the database, this was likely a IdP initiated
93
# create an account for them, and give them a temp password
94
user = User.new(
95
email: email,
96
password: ::Devise.friendly_token[0, 20],
97
terms_of_service: true,
98
name: email
99
)
100
user.set_new_user_default_preferences
101
user.skip_confirmation!
102
user.account_users.new(account: @idp_config.account)
103
user.save!
104
end
105
106
# remember the users to they don't have to login again
107
user.remember_me = true
108
sign_in(user)
109
session[:account_id] = @idp_config.account_id
110
session[:sso] = true
111
redirect_to root_url(host: Rails.application.routes.default_url_options[:host]), allow_other_host: true # force them back to the main site
112
else
113
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
114
end
115
end
116
117
# See https://github.com/onelogin/ruby-saml#service-provider-metadata
118
def saml_metadata
119
raise ActionController::RoutingError.new(t(".not_found")) unless @idp_config
120
settings = get_saml_settings(@idp_config)
121
meta = OneLogin::RubySaml::Metadata.new
122
render xml: meta.generate(settings), content_type: "application/samlmetadata+xml"
123
end
124
125
# Trigger SP and IdP initiated Logout requests
126
def saml_logout
127
if params[:SAMLRequest]
128
# If we're given a logout request, handle it in the IdP logout initiated method
129
saml_idp_logout_request
130
elsif params[:SAMLResponse]
131
# We've been given a response back from the IdP, process it
132
saml_process_logout_response
133
end
134
end
135
136
# 2FA code snipped for example brevity
137
138
def find_user
139
if sign_in_params[:email].present?
140
resource_class.find_by_email(sign_in_params[:email].downcase)
141
end
142
end
143
144
def get_saml_settings(idp_config)
145
settings = OneLogin::RubySaml::Settings.new(idp_config.settings)
146
# From the gem docs - "The use of settings.issuer is deprecated in favour of settings.sp_entity_id since version 1.11.0"
147
# Sett IdpConfig model for the branching of the logic between v3 and v4
148
settings.assertion_consumer_service_url = idp_config.assertion_consumer_service_url
149
settings.sp_entity_id = idp_config.sp_entity_id
150
151
settings
152
end
153
154
def set_idp_config
155
sp_entity_id = params[:sp_entity_id]
156
if sp_entity_id.present?
157
Rails.logger.debug "SSO set_idp_config - sp_entity_id: #{sp_entity_id}"
158
@idp_config = IdpConfig.find_by(sp_entity_id: sp_entity_id)
159
end
160
end
161
162
def verify_can_username_password
163
user = find_user
164
return unless user&.requires_sso_signin?
165
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
166
end
167
168
# Method to handle IdP initiated logouts
169
def saml_idp_logout_request
170
settings = get_saml_settings(@idp_config)
171
logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest])
172
173
if !logout_request.is_valid?
174
error_message = "IdP initiated LogoutRequest was not valid!"
175
Rails.logger.error error_message
176
return render inline: error_message
177
end
178
179
email = logout_request.name_id.downcase
180
Rails.logger.debug "IdP initiated saml_idp_logout_request for #{email}"
181
182
# Actually log out this session
183
user = User.find_by_email(email)
184
sign_out(user) if user
185
186
# Generate a response to the IdP.
187
logout_request_id = logout_request.id
188
logout_response = OneLogin::RubySaml::SloLogoutresponse.new.create(settings, logout_request_id, nil, RelayState: params[:RelayState])
189
redirect_to logout_response, allow_other_host: true
190
end
191
192
# Create a SP initiated SLO
193
def saml_sp_logout_request(idp_config, user)
194
# LogoutRequest accepts plain browser requests w/o paramters
195
settings = get_saml_settings(idp_config)
196
197
if settings.idp_slo_service_url.present?
198
email = user.email
199
200
logout_request = OneLogin::RubySaml::Logoutrequest.new
201
Rails.logger.debug "New SP SLO for userid '#{email}' transactionid '#{logout_request.uuid}'"
202
203
settings.name_identifier_value = email
204
205
# Save the transaction_id to compare it with the response we get back
206
session[:transaction_id] = logout_request.uuid
207
208
relay_state = saml_logout_url
209
redirect_to(logout_request.create(settings, RelayState: relay_state), allow_other_host: true)
210
end
211
end
212
213
def saml_process_logout_response
214
return redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), alert: t(".not_found"), allow_other_host: true unless @idp_config
215
216
settings = get_saml_settings(@idp_config)
217
218
if session.has_key?(:transaction_id)
219
logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings, matches_request_id: session[:transaction_id])
220
session.delete(:transaction_id)
221
else
222
logout_response = OneLogin::RubySaml::Logoutresponse.new(params[:SAMLResponse], settings)
223
end
224
225
# Validate the SAML Logout Response, but we don't do anything besides basically log it (we can't do anything about it)
226
collect_errors = true
227
if !logout_response.validate(collect_errors)
228
Rails.logger.error "The SAML logout response is invalid: #{logout_response.errors.join(", ")}"
229
end
230
231
redirect_to sso_url(host: Rails.application.routes.default_url_options[:host]), allow_other_host: true
232
end
233
end
234

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.