Configuring a Rails App for Single Sign On with SAML Against Multiple Providers

What is Single Sign On?

Ever land on an app’s login page and feel that dread of needing to remember your credentials? Or needing to open your password manager 😉 ? Applications can also authorize you through external providers. You’ve seen the “Sign In With Facebook / Twitter / Google” buttons. That right there is an implementation of Single Sign On.

For a recent project, we were asked to implement Single Sign On so high schoolers could easily access our application when logged in to their external system. We’ll name our application ✏️ and the students’ external system 🔮  for reference later on. Let’s define some terms…

Single Sign On (SSO): Using one set of credentials to authenticate against related but individual applications.

Identity Provider (IdP): Holder of credentials, asserts to another system that a user is known and authenticated. This is generally Facebook, Twitter, Google, etc. For our example, this is 🔮 .

Service Provider (SP): Reads the token or cookies to allow authentication from the IdP. This is our app, ✏️ .

How does SSO work?

  1. User requests a service by visiting ✏️
  2. ✏️  requests an identity assertion from 🔮
  3. User enters credentials for 🔮 (Often this step will be unnecessary as the user is already logged in to 🔮)
  4. ✏️  obtains the confirmed identity assertion and authenticates the user

Requirements

The ✏️  app is a standard Rails application using the Devise gem for authentication. ✏️  already provides a standard email and password login flow. To make authentication as easy as possible for future students, we will include a button to “Sign In With 🔮 ” on the login page.  

Two more (major) requirements were confirmed before development started. 1) We need to be able to switch out the 🔮  IdP with a different IdP at the time of the service request. This is because each state or even individual school may have their own IdP. 2) The SSO implementation must use the SAML protocol.

A couple quick definitions before discussing the solution:

Omniauth: a Ruby convention to abstract and generalize how to hook any authentication provider into Rack middleware.  For a Rails app, there are over 250 strategies listed, including Facebook, Twitter, Google, and SAML. We’ll be using SAML.

Security Assertion Markup Language (SAML): an XML-based data format for exchanging authentication information between an IdP and an SP. Because of the standard formatting, we are able to disassociate ✏️ implementation from the 🔮 implementation.  The platform doesn’t matter, SAML asserts the formatting. Here’s an example SAML document for authenticating a Service Provider against the Google SAML IdP:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://accounts.google.com/o/saml2?idpid=C0149q5tr" validUntil="2022-01-25T22:44:33.000Z">
 <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
   <md:KeyDescriptor use="signing">
     <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
       <ds:X509Data>
         <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAVnc87TaMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMTcwMTI2
MjI0NDMzWhcNMjIwMTI1MjI0NDMzWjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAq0IcM+dRtnNMDTbK4n0B/UGuYF+GgT75qX1h46oFsXuA9US2Fqwtk2NOEPmhBXqT
Yj4GNTtqXD31Hz35YMqORdREP/5w02HsBJPasVJLO+I9xMRnOO43Y89HrHs8FvRmCf9DG5b2At/k
SACUpKwJaFBDvuNg3a10HQ41AtSTyckxpeyRMsoWTDkmv0YeGLh/p8tQwKI2/gywP06OPQWnk0Tc
gdsv7mcxgpfAO1dxvbmDw33PceUkRyOYHAGaR1TQE27vxN4Mm7frY+9VPdiT6lkfIY/zokbw0F/j
SsZqpHLBnLJANzmSY3MTWPBtjCFmGFXyVlk2MA0DGXXHxyt0QwIDAQABMA0GCSqGSIb3DQEBCwUA
A4IBAQCR6Rq58whQdqxaghn2HNLWiWrbN2WWrl93VTNNyU8YabFeZysDHXSKcGJVpVs0TEzFR/fZ
kpTIu8PELhAG+SvDDRgYL+BLZZai8h8Hl6vX/MW4S6EVZ1isz3TV3k/6X8AyREdQ+aglsZLKZesv
bVaIy9ICZr9Ze156yR6ugxVG9lfuGaHfPddtzd1oTTsqO0cPTbvU7b8sXwA4GMG+lMqqOsien0u+
OMKIMvIIdl+iCxR89AMz7Dd0cYOlqRXrv2FJVOM5/NBCtB7RzaXLfOknrsa1s3Atq2U3zH4Xofxu
f//0+/ISXDQonVOM/+e59LT72FL+kx0J/V2gAsV/KHrt</ds:X509Certificate>
       </ds:X509Data>
     </ds:KeyInfo>
   </md:KeyDescriptor>
   <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
   <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
     Location="https://accounts.google.com/o/saml2/idp?idpid=C0149q5tr"
     Location="https://accounts.google.com/o/saml2/idp?idpid=C0149q5tr"/>
   <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
 </md:IDPSSODescriptor>
</md:EntityDescriptor>


Implementation

As with most Rails applications, the maturity of the ecosystem means a solution is available with very little configuration.  Check out the omniauth-saml gem, specifically the integration with Devise.

Referring to the gem’s documentation, you’ll notice that we have the option to use an idp_cert_fingerprint or an idp_cert.

Cert: Our example SAML document has a full cert, not a fingerprint, so we can plug that into the Devise setup. See the <ds:X509Certificate> tag.

Target URL: After playing around with some of the other data in the SAML document, we found that the URL listed in the <md:SingleSignOnService> tag is the appropriate value for this option.

Devise.setup do |config|
  config.omniauth :saml,
    idp_cert: 'cert',
    idp_sso_target_url: 'target_url'
end

The devise omniauth documentation will take you through an example using Facebook as a provider.  Following that structure, but replacing facebook with saml

Add gem “omniauth-saml” to the Gemfile.

Create a migration to add uid and provider to the users table. The uid field will allow us to match the user between the ✏️  and 🔮  systems.  The provider field will default to :saml.

Configure the User model by adding the :omniauthable option to the Devise options:

class User
  # omitting the rest of the class
  devise :omniauthable, omniauth_providers: [:saml]
end

This will expose the omniauth_authorize_path(:user, :saml) #=> /users/auth/saml route for starting the authorization with 🔮 .

We have to tell Devise in which controller we will implement the Omniauth callbacks, by adding an options to the routes.rb file. Then, we can define the saml action in the controller to read the request environment and find the user in ✏️ from the authorization details sent by 🔮 .

Rails.application.routes.draw do
  # omitting other routes
  devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" }
end
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token

  def saml
    auth_hash = request.env["omniauth.auth"]
    user = User.find_by(uid: auth_hash[:uid])
    # do stuff
    sign_in_and_redirect user
  end
end

So… great! We can plug in those configuration options, expose a button on the login page, and the user flow would be as follows:

  1. Visit ✏️  login page
  2. Click button “Sign In With 🔮 ”, using omniauth_authorize_path(:user, :saml) path
  3. Enter credentials on 🔮
  4. Sent back to ✏️ , specificially the users/omniauth_callbacks#saml action
  5. Update data, check user status, etc.
  6. ✏️  signs in the user using Devise’s sign_in_and_redirect user method

Are we done??

….. almost there. One of the requirements of the system was to dynamically send the user to different IdPs depending on their state / school. Meaning, in some cases we’ll need to authenticate against the 🔮 IdP, but for other users we may need to authenticate against a different IdP.

This means that we cannot configure the IdP with the Devise initializer. Instead, we need to set up request-time modification to change the cert and target_url for each auth request. Reading through the omniauth code, you’ll see a method setup_phase, called before the request processes.  By passing in a proc (or anything that response to .call) to the setup: option, we can configure which auth provider to use before the request:

require “omniauth_saml_setup”

Devise.setup do |config|
  # omitting the rest of the config block
  config.omniauth :saml, setup: OmniauthSamlSetup
end
class OmniauthSamlSetup
  # Omniauth expects the class passed to setup to respond to the #call method.
  # env - Rack environment
  # This class is Rack middleware, we put it in the "lib/" directory
  def self.call(env)
    new(env).setup
  end

  def initialize(env)
    @env = env
  end

  def setup
    @env["omniauth.strategy"].options.merge!(saml_settings)
  end

  private

  def saml_settings
    # find your provider, given a subdomain or a query param
    provider = Provider.find_by(foo: params[:bar])
    {
      idp_cert: "-----BEGIN CERTIFICATE-----\n#{provider.cert}\n-----END CERTIFICATE-----",
      idp_sso_target_url: provider.target_url
    }
  end
end

🎉  🎉  🎉  🎉  🆒  🆒  🆒  🆒

Glossary

DevMynd is custom software development company in Chicago and San Francisco with practice areas in digital strategy, UI/UX, and custom mobile and web application development.

Matt is a member of DevMynd’s software engineering team focusing on mobile apps and web development. He has been with the company since 2015.