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?
- User requests a service by visiting ✏️
- ✏️ requests an identity assertion from 🔮
- User enters credentials for 🔮 (Often this step will be unnecessary as the user is already logged in to 🔮)
- ✏️ 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:
- Visit ✏️ login page
- Click button “Sign In With 🔮 ”, using
omniauth_authorize_path(:user, :saml)
path - Enter credentials on 🔮
- Sent back to ✏️ , specificially the
users/omniauth_callbacks#saml
action - Update data, check user status, etc.
- ✏️ 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
- Single Sign On
- Omniauth
- SAML
- Cert and Target URL
- Basic configuration with Devise
- Callbacks controller
- Dynamic provider
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.