DEV Community

loading...
Cover image for Add SAML SSO to a Rails 6 app

Add SAML SSO to a Rails 6 app

sbauch profile image Sam Bauch ・26 min read

SAML SSO refers to an authentication mechanism preferred by enterprise companies. The SSO part stands for Single Sign-On. From the enterprise’s perspective, they desire a centralized service where their employees can authenticate, which then provides authenticated access to the applications they use for work. These services are called Identity Providers (IDPs), and they are the Single place where enterprise employees Sign-On. This is an alternative to employees using password-based authentication for each of the various applications they use.

The SAML part stands for Secure Assertion Markup Language. SAML utilizes a domain-specific flavor of XML that describes an authenticated user, encoded to a string and passed to your application in a query parameter. Your application decodes the SAMLResponse using a key that the enterprise provides to you.

The details of the SAML Response are less important than understanding the general flow. When a user wants to sign in using SAML, you must send that user to their Identity Provider with a SAMLRequest that identifies your application. The user signs in to their IDP, and is redirected back to you with the SAMLResponse. What makes this a tiny bit complicated for a multi-tenant application is routing the user to the correct IDP, as SAML SSO breaks our common understanding of a login flow.

A typical login screen might have an email and password field, then a few buttons to sign in with OAuth services, like Google or GitHub. But now, you’ll need to gather something from the user who wants to sign in such that you can send them to the correct IDP instance. We like to say that SAML is instance based, especially whencompared to OAuth, which we can think of as class based. Domain is a common key for this process - you can ask a user for their email, grab the domain, and use that as a key to find the correct IDP instance and redirect the user.

Let’s Build and Iterate

Once you wrap your head around understanding the SAML flow, it’s really not challenging to implement SAML in a Rails app - IDPs themselves offer great open source libraries to help with your integration. You can definitely get very far in a day or two. But like most things in engineering, the last 10% can take 90% of the effort, and while we all love to say “I could build that in a weekend,” we often later eat our words when we realize all of the edge cases, documentation needs, scaling challenges and unknown unknowns that make this or any other project worth doing a bit more involved than a quick weekend hack.

I’m a co-founder of an open source company Osso - our microservice is a Ruby and React app that allows you to onboard SAML SSO customers, generates custom documentation for each customer to perform their onboarding tasks in their IDP, and allows your Rails app to consume Osso using OAuth - we even provide omniauth-osso to make consuming an Osso instance from your Rails app incredibly simple.

Osso also offers paid plans, and you can skip right to our pricing page to learn more about hosted Osso if you need SAML yesterday. Or you can jump right in to deploying an open source Osso instance from our GitHub repo. There’s certainly the possibility of using osso-rb in your Rails app directly — you’ll want to mount our 3 rack based apps, pull in migrations, etc. — but this post will focus instead on a microservices approach.

So, if you’d like to begin from first principles, follow along here as we build up a production-ready SAML SSO integration, starting with a single tenant approach, then layering in multi-tenancy. Once we have multi-tenant support, we’ll address questions about scalability and serviceability — how can we repeatedly add additional tenants, without spending significant engineering or customer support cycles? I will encourage you to use Osso, and show you exactly how, but will also highlight the things you need to be considering in order to roll out a production ready SAML integration without Osso.

We’ll use a single Rails app repository throughout this tutorial. The main branch is a barebones Rails 6 app with Devise and a User model, and for each of our steps below we’ll move to a feature branch so we can see a full diff. Each step adds more functionality, getting you closer to a production-ready, multi-tenant integration. Each step also uses open source software, where the library used in the previous step is a dependency of the next step’s library. Neat!

We’ll also make use of Osso’s mock IDP and demo instance, especially when we get into multi-tenancy. You’ll still want to register with an IDP for a developer account in order to have access to an IDP for a second tenant - we recommend an Okta developer account.

Base Rails application

We’ll use the same Rails 6 application for each step - the source code is available here. The main branch adds Devise with a User model to a brand new Rails app using Postgres as the database. The application doesn’t do anything - there is an index route and a logged in route that will tell you the email for the current user. This guide assumes proficiency with Rails, so it will skip some details. We’ll also use some intentionally naive approaches to demonstrate common challenges of SAML SSO.

Single tenant

With ruby-saml and mock IDP

My first experience with SAML was working on internal software at WeWork where one of my Osso co-founders was a teammate. The IT department required us to use SAML SSO, which I at first was awfully annoyed by. Google OAuth was super easy, and could be restricted to domain, so what’s the deal with this OneLogin thing? Fortunately, single-tenant SAML was just as simple to implement, and admittedly did include some better security. This is a common situation — an enterprise company requires SAML, and it might seem silly to you, but them’s the rules.

Our project was a Rails app, and we used a Ruby gem from OneLogin to handle the actual SAML encoding and decoding. Since we were building this app for WeWork employees, we had a single tenant, and didn’t need to worry about implementing a scalable solution for multiple tenants, documenting how to set SAML up for the app or training our teammates on how it worked. We also didn’t need to change our login form - we knew every user would be sent to the same IDP instance, so we were able to use a simple Sign in With OneLogin button.

That’s what we’ll do here, and if you’re building internal software this will be a fine approach for your production release! We’ll use Osso’s mock IDP for this single tenant to keep things simple too.

First lets install the ruby-saml gem. We’ll include it in our Gemfile and run $ bundle install.

  gem 'ruby-saml', '~> 1.9.0'
Enter fullscreen mode Exit fullscreen mode

We’ll use this library in two ways:

First, when a user wishes to sign in with SAML, we use ruby-saml to generate a url with a SAMLRequest - the URL is where we will send the user to sign in, and the SAMLRequest is sent along as a query param in order to identify our application to the IDP.

When the user signs in to their IDP, they will be sent back to your application with a SAMLResponse query param. We then use ruby-saml to decode and validate the SAMLResponse, allowing us to access information describing the user, such as their email address.

Lets define a couple of routes and controller actions to handle this flow.

  get 'saml_login', to: "application#saml_login"
  post 'saml_callback', to: "application#saml_callback"
Enter fullscreen mode Exit fullscreen mode

In the saml_login action, we create a OneLogin::RubySaml::Authrequest and redirect to the return value. Later, in the callback, we validate and decode the response, and will sign the user in and redirect them if valid. Otherwise we’ll raise the validation errors to understand where our SAML config went wrong.

This callback route will also need to accept a POST request with www-url-form-encoded parameters, so you will need to skip the Rails verify_authenticity_token before_action:

  skip_before_action :verify_authenticity_token, only: :saml_callback

  def saml_login
    request = OneLogin::RubySaml::Authrequest.new
    redirect_to(request.create(saml_settings))
  end

  def saml_callback
    response = OneLogin::RubySaml::Response.new(
      params[:SAMLResponse],
      :settings => saml_settings
    )

    if response.is_valid?
      @user = User.create_or_find_by!(email: response.nameid)
      sign_in(@user)
      redirect_to(:logged_in)
    else
      raise response.errors.inspect
    end
  end
Enter fullscreen mode Exit fullscreen mode

Both of these actions depend on what ruby-saml calls settings. These values are created via the configuration you must perform between your application and the Identity Provider. In a real-world example, your application generates a few values which you provide to your customer. The customer uses these to configure your application in their IDP and returns some data generated by the IDP. We’ll discuss this process in more detail later. Since we are using the Osso Mock IDP we can mostly skip this configuration step, but it’s worth understanding what each of these values represents, how it functions and why it might prevent your user from signing in if misconfigured.

Each of the controller actions above calls a private method saml_settings. Since we are dealing with a single tenant, we will essentially hardcode the SAML configuration values to support this one IDP instance.

  private

  def saml_settings
    settings = OneLogin::RubySaml::Settings.new

    # You provide to IDP
    settings.assertion_consumer_service_url = "http://#{request.host_with_port}/saml_callback"
    settings.sp_entity_id                   = "my-single-tenant"

    # IDP provides to you
    settings.idp_sso_target_url             = "https://idp.ossoapp.com/saml-login"
    settings.idp_cert                       = Rails.application.credentials.idp_cert

    settings
  end
Enter fullscreen mode Exit fullscreen mode

Values you provide

ACS URL - The Assertion Consumer Service URL is where the IDP will send the user with a SAMLResponse when they log in. Similar to a Redirect URI in OAuth, your application will generate this value according to how your routes are set up.

SP Entity ID - Sometimes called the Audience URI, this is a unique identifier for the tenant in your application. This can get hairy! Google requires that they be unique for a customer, so you can't just use domain, while Azure won't let you use a UUID and Ping won't let you use a url. Those are just some of the edge cases we've found!

Values the IDP provides

IDP SSO Target URL - The single sign on target URL tells your application where to send a user with a SAMLRequest in order to sign in.

IDP Certificate - An x509 certificate which includes a public key that your application uses to encode and decode the SAML request and response.

Since we are using the Mock IDP, these values can be found in the Mock IDP’s federated metadata: https://github.com/enterprise-oss/sinatra-ruby-idp/blob/main/metadata.xml

To enforce good security practices, we put the certificate in Rails credentials. We won't show you our whole certificate here, but here's part of an example credentials file that shows both single and multiline certificates:

Rails credentials file

Finally, we need to give a way for the user to actually log in. We’ll add a Sign In button to the index route. We’re able to use a single button due to the fact we are supporting only one tenant - we know we’ll send every user to the same SSO URL.

  <div class="container">
    <div class="main-content">
      <h1>Welcome!</h1>
      <% flash.each do |name, msg| %>
        <% if msg.is_a?(String) %>
          <div class="alert alert-<%= name == :notice ? "success" : "error" %>">
            <%=raw content_tag :div, msg, id:"flash_#{name}" %>
          </div>
        <% end %>
      <% end %>
      <!-- Add a button to allow signing in -->
      <%= button_to 'Sign in with SAML SSO', action: :saml_login %> 
    </div>  
  </div> 
Enter fullscreen mode Exit fullscreen mode

That’s it! You should now be able to sign in using SAML against the Osso Mock IDP, which takes any email / password combination. You can see the whole diff for this approach at this Pull Request: https://github.com/enterprise-oss/saml-rails/pull/4

Multi-tenant

with omniauth-multi-provider and omniauth-saml

A single-tenant SAML integration is fine if you’re building internal software. But if your application is a multi-tenant SAAS app and you’re starting to sell to bigger, security-minded enterprises you need to support multi-tenancy.

The main engineering requirement to think about is how to surface the relevant SAML configuration values when a user wants to sign in with SAML. In our single-tenant approach, we were able to hard code these values for the single tenant. We’ll also hard code values in this multi-tenant approach before discussing the weaknesses of this approach. We’ll also need to update our sign in UX in order to ascertain which tenant a user belongs to in order to send them to the correct IDP.

We’ll also use the second tenant to demonstrate SAML configuration in an Identity Provider and discuss the documentation challenges. We recommend signing up for an Okta developer account for your testing purposes - https://www.okta.com/developer/signup

We’ll use a Ruby gem omniauth-multi-provider to support SAML multi-tenancy. omniauth-saml will handle that actual SAML bits, and this library uses ruby-saml internally, much in the same manner we used it in the single-tenant approach. OmniAuth is a Ruby library that “standardizes multi-provider authentication for web applications.” If you’ve implemented OAuth in a Rails app you’re likely familiar with this library. It integrates well with Devise, and offers a framework for engineers to create Strategies for OmniAuth for authenticating against external services. If you’re not familiar with OmniAuth it’s worth familiarizing yourself - https://github.com/omniauth/omniauth.

To begin, let’s add omniauth-multi-provider and omniauth-samlto our Gemfile, and we can remove the ruby-saml gem:

  gem 'omniauth-multi-provider'
  gem 'omniauth-saml', '= 1.10.3'
Enter fullscreen mode Exit fullscreen mode

We’ll also extend our Devise configuration in the User model to integrate with OmniAuth:

  class User < ApplicationRecord
    devise :timeoutable, :omniauthable
  end
Enter fullscreen mode Exit fullscreen mode

OmniAuth will handle our routes now, so let’s update our routes.rb, replacing the routes and controller actions we created in our single-tenant branch. We wrap these routes in a devise_scope block in order to let Devise know we want to map these routes to the User resource:

  devise_scope :user do
    post '/auth/saml/:identity_provider_id/callback',
      to: 'omniauth_callbacks#saml',
      as: 'user_omniauth_callback'
    post '/auth/saml/:identity_provider_id',
      to: 'omniauth_callbacks#passthru',
      as: 'user_omniauth_authorize'
  end
Enter fullscreen mode Exit fullscreen mode

We do need to create this OmniauthCallbacks controller, and it will inherit the Devise::OmniauthCallbacksController. We only need to define the saml controller action - omniauth-multi-provider acts as a sort of meta-provider as we’ll see below. We also need to skip verifying the authenticity token - some IDPs will submit the callback request as a POST with www-url-form-encoded parameters, and we need to allow that on this route. Since we want actions added in the future to enforce CSRF, let's be sure to allow-list the saml action rather than disable the check for the whole controller.

  class OmniauthCallbacksController < Devise::OmniauthCallbacksController
    protect_from_forgery with: :exception, except: :saml 

    def saml
      auth_hash = request.env['omniauth.auth']
      @user = User.create_or_find_by!(email: auth_hash['uid'])

      sign_in(@user)

      redirect_to(:logged_in)
    end
  end
Enter fullscreen mode Exit fullscreen mode

The omniauth-multi-provider gem offers some Rack middleware that will intercept requests to each of these routes. You’ll want to add an initializer that allows our OmniAuth implementation to route a user to the relevant IDP. On the callback, the middleware will intercept the request, handle SAML validation, and convert the SAMLResponse into an omniauth authentication hash, passing the request on to your controller action:


  SAML_SETTINGS = {
    'example.com': {
      issuer: "my-single-tenant",
      idp_sso_target_url: "https://idp.ossoapp.com/saml-login",
      idp_cert: Rails.application.credentials.idp_cert,
    }
  }

  Rails.application.config.middleware.use OmniAuth::Builder do
    OmniAuth::MultiProvider.register(
      self,
      provider_name: :saml,
      identity_provider_id_regex: /[a-z]*/,
      path_prefix: '/users/auth/saml',
      callback_suffix: 'callback',
    ) do |identity_provider_id, rack_env|
      request = Rack::Request.new(rack_env)
      SAML_SETTINGS[identity_provider_id.chomp('/callback').to_sym].merge({
        assertion_consumer_service_url: acs_url(request.url)
      })
    end

    def acs_url(request_url)
      url = request_url.chomp('/callback')
      url + '/callback'
    end
  end
Enter fullscreen mode Exit fullscreen mode

This block must return the same saml_settings we saw in the single-tenant approach for both the request and callback actions - omniauth-saml uses ruby-saml under the hood, so the keys are the same. We’ve replaced our private controller method with a class constant that includes the same attributes for the same tenant. We’re using a hash with a key of example.com - in order to use these SAML settings, we need to submit a POST request to /users/auth/saml/example.com. This sorta breaks Rails conventions, so we'll adjust it later.

We also add a convenience method here for the ACS url - since this block is used for both the request and callback, we need to be a little hacky to ensure this block always returns a hash with the proper callback path - a better approach, as we’ll see later, would be to derive all of these config values from a model instance.

With that in place, we can slightly adjust our login flow and should still be able to log in using the first tenant. We’re hardcoding the identity_provider_id as example.com to match the key above. We’re not quite supporting multi-tenancy yet, but most of the parts are in place.

  <%= button_to 'Sign in with SAML SSO', user_omniauth_authorize_url(identity_provider_id: 'example.com') %> 
Enter fullscreen mode Exit fullscreen mode

Now let’s get a second tenant onboarded! We’ll use Okta as the IDP for the second tenant. You can sign up for a free developer account at https://developer.okta.com/signup/

Once you have an account, configure our Rails app in your Okta instance. We’ll wait.

[some stupid waiting gif]

Feeling a bit lost? That’s what your enterprise customers will experience if you don’t provide them documentation. You’ll want to create a SAML 2.0 Web App, and the Okta form even suggests that the app you’re trying to integrate should provide instructions.

Lots of SaaS companies take a similar approach where they provide one-size-fits-all documentation to configure an application in an IDP, and then separately provides the configuration values.

Osso takes a slightly different approach and generates bespoke documentation for end users in a portable and easy to use format. Here’s a doc we generated that you can use to set up the demo app in your Okta instance:

Once you configure the demo app in your Okta instance, you’ll be able to access the configuration values you need to support the second tenant.

  SAML_SETTINGS = {
    'example.com' => {
      issuer: "my-single-tenant",
      idp_sso_target_url: "https://idp.ossoapp.com/saml-login",
      idp_cert: Rails.application.credentials.idp_cert,
    },
    'your-email-domain.com' => {
      issuer: "my-single-tenant",
      # SSO URL provided by Okta specific to your instance
      idp_sso_target_url: 'https://dev-634049.okta.com/app/dev-634049_railsdemo_1/exk1yj3meeGRUVVT04x7/sso/saml',
      idp_cert: Rails.application.credentials.okta_idp_cert, # x509 cert provided by Okta
    }
  }
Enter fullscreen mode Exit fullscreen mode

We should be able to log in using your Okta instance by switching the hardcoded identity_provider_id in the login form to match the key we just added in the SAML_SETTINGS hash. These keys are somewhat arbitrary, but by using a domain we ensure uniqueness for what we can understand as a tenant, and we’ll be able to offer a nice sign in UX when we want to support both SAML and password based logins in the same form. Lets test the Okta tenant before extending this form to actually support multi-tenant logins.

  <%= button_to 'Sign in with SAML SSO', user_omniauth_authorize_url(identity_provider_id: 'your-email-domain.com') %> 
Enter fullscreen mode Exit fullscreen mode

Once you’ve successfully authenticated against the Mock IDP and your Okta instance, let’s make this login form support multi-tenancy. Due to omniauth-multi-provider's RESTful route approach, we need to add another route that at first may seem to just be adding indirection, but it will become useful as we improve our login form.

The route we need to add will be responsible for receiving the login form POST request. For now, we’ll just redirect the user to the user_omniauth_authorize route. We won’t want to use this in production - the recently released OmniAuth 2.0 removes support for GET requests in the request phase due to security concerns, and that’s what we’re doing here with this redirect.

You’ll likely notice some other half-baked approaches here.

  post '/users/auth/saml_idp',
    to: 'application#idp_login',
    as: 'user_idp_discovery'
Enter fullscreen mode Exit fullscreen mode
  TENANTS = {
    'example.com' => "08909493-cc6f-4a67-9986-f8f4452ba1d4",
    'your-email-domain.com' => "4eaff58f-40a2-4ebe-b746-a9dbe2103864"
  }

  def idp_login
    domain = params[:email].split('@').last
    idp_id = TENANTS[domain]

    redirect_to(
      user_omniauth_authorize_path(identity_provider_id: idp_id)
    ) if idp_id

    status 401
  end
Enter fullscreen mode Exit fullscreen mode

This action is responsible for accepting an email form parameter, parsing the domain, and using the domain to look up a UUID for a SAML tenant. In our case we’ve generated UUIDs and are looking up the domains in a hash, but we could also start to think about persisting these values in the database. We could redirect using the domain name, but that breaks some Rails conventions, and when something is named ID we should use an ID to meet our teammates expectations. We also need to copy these UUIDs over to our SAML_SETTINGS hash, though you could also start to think about persisting the SAML settings in the database as well as we’re starting to get a little copy-paste messy.

  SAML_SETTINGS = {
    '08909493-cc6f-4a67-9986-f8f4452ba1d4' => {
      issuer: "my-single-tenant",
      idp_sso_target_url: "https://idp.ossoapp.com/saml-login",
      idp_cert: Rails.application.credentials.idp_cert,
    },
    '4eaff58f-40a2-4ebe-b746-a9dbe2103864' => {
      issuer: "my-single-tenant",
      idp_sso_target_url: ''# SSO URL provided by Okta,
      idp_cert: Rails.application.credentials.okta_idp_cert, # x509 cert provided by Okta
    }
  }
Enter fullscreen mode Exit fullscreen mode

Since we’re now using UUIDs, we’ll also want to change the regex used by omniauth-multi-provider:

  UUID_REGEXP =
        /[0-9a-f]{8}-[0-9a-f]{3,4}-[0-9a-f]{4}-[0-9a-f]{3,4}-[0-9a-f]{12}/.
          freeze

  Rails.application.config.middleware.use OmniAuth::Builder do
    OmniAuth::MultiProvider.register(
      self,
      provider_name: :saml,
      identity_provider_id_regex: UUID_REGEXP,
      path_prefix: '/users/auth/saml',
      callback_suffix: 'callback',
    ) do |identity_provider_id, rack_env|
      #...
    end
  end
Enter fullscreen mode Exit fullscreen mode

Now we can adjust our login form to use an email input, and hit the idp_login route

  <%= form_with url: idp_login_url, method: 'post', class: 'login-form' do |form| %>
    <%= form.label :email, "Email" %>
    <%= form.email_field :email %>
    <%= form.submit "Sign in with SAML SSO" %>
  <% end %>
Enter fullscreen mode Exit fullscreen mode

You should be able to sign in using either tenant now! We’re definitely making progress - the login form scales reasonably well for SAML users, but it won’t support falling back to password. And we really should address the GET request we’re making in our redirect in order to be ready for OmniAuth 2.0.

We’re going to write a little bit of Javascript to help. If you’re using a front-end framework like React this will give you the general idea of what you’ll want to do with a login form, but we’ll stick with unobtrusive vanilla JS.

We’re going to update our idp_login action to accept an ajax request and return json. If we submit an email where a SAML tenant exists for the email’s domain, then we’ll return the SAML Tenant UUID, and use that to append another form on the page and submit it. We’ll now be posting to the user_omniauth_authorize_url with a UUID that we know maps to a SAML tenant. If we don’t get a UUID back, then we know the user will need to provide a password to sign in and we can display a password input. We’re a little hacky here again with things like the authenticity token, and we won’t actually implement the password login, but this should serve as a fine example for what you’ll want to do however you write JS.

  document.addEventListener("turbolinks:load", function() {
    const loginForm = document.querySelector("#login-form");
    loginForm.addEventListener("ajax:success", (event) => {
      const [data, ..._rest] = event.detail;
      if (data.identity_provider_id) {
        return samlLogin(data.identity_provider_id)
      }
      passwordLogin(loginForm)
    });
  });

  function samlLogin(idpId) {
    const form = document.createElement("form");
    const tokenInput = document.createElement("input"); 
    const csrfToken = document.querySelector('meta[name="csrf-token"]').content
    tokenInput.value = csrfToken;
    tokenInput.name = "authenticity_token"
    form.action = `/users/auth/saml/${idpId}`;   
    form.hidden = true
    form.method = "POST";

    form.appendChild(tokenInput);  
    document.body.appendChild(form);

    form.submit();
  }

  function passwordLogin(loginForm) {
    const passwordInput = document.createElement("input"); 
    passwordInput.name = "password"
    loginForm.appendChild(passwordInput);
    loginForm.action = '/users/auth/password'   
  }
Enter fullscreen mode Exit fullscreen mode

Now let’s update our controller action:

  def idp_login
    domain = params[:email].split('@').last
    idp_id = TENANTS[domain]
    render json: { identity_provider_id: idp_id }
  end
Enter fullscreen mode Exit fullscreen mode

And we can also adjust our routes:

  devise_scope :user do
    post '/users/auth/saml_idp',
      to: 'omniauth_callbacks#idp_login',
      as: 'user_idp_discovery'
    match '/users/auth/saml/:identity_provider_id/callback',
      via: [:get, :post],
      to: 'omniauth_callbacks#saml',
      as: 'user_omniauth_callback'
    post '/users/auth/saml/:identity_provider_id',
      to: 'omniauth_callbacks#passthru',
      as: 'user_omniauth_authorize'
  end
Enter fullscreen mode Exit fullscreen mode

We’re now POSTing to the user_omniauth_authorize route, and including the authenticity token. But we still need to protect this route from CSRF attacks, which we can use the omniauth-rails_csrf_protection gem to achieve this. See this security vulnerability for more information on why this is important. Adding this to your Gemfile includes middleware that locks down your OmniAuth routes:

  gem 'omniauth-rails_csrf_protection'
Enter fullscreen mode Exit fullscreen mode

And that does it! We’ve now got a working proof of concept for multi-tenancy. The login form can scale well and support other auth approaches if needed. We’re using POST requests with authenticity tokens for OmniAuth so we’re ready for OmniAuth 2.0 and practicing good security. You can see the whole diff for this branch here - https://github.com/enterprise-oss/saml-rails/pull/5

A couple of things stand out as issues that should be improved. First, we’re hardcoding things still. That does not scale well - we don’t want to ship a new release with hardcoded data every time we want to onboard a new customer. The obvious approach is to model the SAML configuration data and persist it in the database. Of course this starts to suggest you need a UI for CRUD on this data. That’s all a bit outside of the scope of this guide, and if you go down this path you’ll discover lots of more threads to pull on, like parsing Federated Metadata XML files for SAML config values.

Another issue here is end-user documentation. We cheated by providing you docs generated by Osso, but if you go down this multi-tenancy path, you’ll need to create similar documentation, and figure out the best way to securely exchange the config values with your customer, recognizing that the software buyer is likely not the same person who has administrative access to their IDP.

Or, you could not bother thinking about any of this, and use Osso to handle your SAML SSO needs.

Production ready with Osso

Osso provides an open source web app that you can use to implement SAML SSO in your Rails app. Osso handles multi-tenant SAML much like we just built in the previous section. But it also provides SAML configuration persistence and an intuitive UI to onboard customers, generating bespoke documentation for each customer to integrate your app in their IDP. In short, Osso solves all of the challenges that the previous libraries don't address. Osso provides the last 10% of a scalable SAML SSO integration that you'd normally need to implement yourself, while saving your engineering team time. Your app consumes Osso using an OAuth2 authorization code grant flow, and Osso provides omniauth-osso to make consuming Osso in your Rails app incredibly simple.

There are alternatives to Osso - Auth0 is a popular choice and works quite similarly to Osso, but doesn't provide documentation for your customers. AWS Cognito and Google Cloud Identity Platform also have support for SAML but also skimp on UI and documentation. Pricing for each of these services is also complicated, opaque and unpredictable.

Osso is available as an open source application that you can deploy yourself. You can also purchase an Osso subscription, and we'll maintain an Osso instance for you - seeOsso's pricing. We also offer a demo instance which we will use in this guide. The demo instance is re-seeded hourly, but will always have a Demo Production OAuth Client and a customer configured against the Osso Mock IDP.

Let's start by adding the omniauth-osso and omniauth-rails_csrf_protection gems - the former for interacting with an Osso instance, and the latter for the same CSRF and POST only protection for OmniAuth as we saw in the previous section. We also want to add omniauth and pin it to a version before 2.0.0 until Devise supports OmniAuth 2:

  gem 'omniauth', '< 2.0.0'
  gem 'omniauth-osso'
  gem 'omniauth-rails_csrf_protection'
Enter fullscreen mode Exit fullscreen mode

Since Osso will handle all the SAML bits, we consume Osso using OAuth, so we'll need to configure the Osso strategy in our Devise initializer. These values are for the demo instance, but you'll want to use your own instance, and should use Rails credentials to store the client ID and secret rather than committing to git.

  config.omniauth(
    :osso,
    'demo-client-id',
    'demo-client-secret',
    client_options: { 
      site: 'https://demo.ossoapp.com',
    }
  )
Enter fullscreen mode Exit fullscreen mode

For our sign in UX, let's iteratively build up a deeply integrated form. Osso supports varying levels fo integration for sign in UX, and we suggest starting by using Osso's hosted login page. If you submit a POST request to /auth/users/osso, the user will be redirected to Osso where they will enter their email address to be routed to their IDP. Let's create a quick little form that will post to this endpoint:

  <div class="container">
    <div class="main-content login">
      <h1>Welcome!</h1>
      <% flash.each do |name, msg| %>
        <% if msg.is_a?(String) %>
          <div class="alert alert-<%= name == :notice ? "success" : "error" %>">
            <%=raw content_tag :div, msg, id:"flash_#{name}" %>
          </div>
        <% end %>
      <% end %>
      <%= form_with(url: '/users/auth/osso', method: 'post', class: 'login-form') do |form| %>
        <button type='submit'>Login with SAML SSO</button>
      <% end %>
    </div>  
  </div> 
Enter fullscreen mode Exit fullscreen mode

With this in place, we can successfully log in again using user@example.com. The demo instance is configured for the example.com domain to use the Mock IDP, and the Mock IDP takes any password, so you should be able to test this easily.

What about a second tenant? If you're following the guide and using the demo instance, you'll be able to onboard another tenant as a customer, but since the data here gets reset, it won't be available beyond your initial testing. The Osso UI should be intuitive, but you can review our Onboarding Customers guide. You can generate docs for yourself for whatever IDP you might have access to. Okta and OneLogin both offer developer accounts, and Google workspaces also allow for SAML apps. You can even add multiple IDPs for your second tenant - Osso will ask the user which service they use to sign in.

If you have a few SAML customers you might stop here - there's sure to be other things you need to work on that are more central to your product, and we are absolutely supporting multi-tenant SAML at this point. But what if we wanted to improve the sign in UX? The Osso hosted login page isn't branded, so it would be best if our users didn't have to hit that.

In our previous section we integrated SAML login into our email / password flow. Some companies take this approach, while others provide an entirely separate login form for SAML SSO. Let's go with the latter for now, where we nonetheless ask the user to enter their email address by adding an email input to our login form.

  <div class="container">
    <div class="main-content login">
      <h1>Welcome!</h1>
      <% flash.each do |name, msg| %>
        <% if msg.is_a?(String) %>
          <div class="alert alert-<%= name == :notice ? "success" : "error" %>">
            <%=raw content_tag :div, msg, id:"flash_#{name}" %>
          </div>
        <% end %>
      <% end %>
      <%= form_with(url: '/users/auth/osso', method: 'post', class: 'login-form') do |form| %>
        <%= form.label :email, "Email" %>
        <%= form.email_field :email %>
        <button type='submit'>Login with SAML SSO</button>
      <% end %>
    </div>
  </div> 
Enter fullscreen mode Exit fullscreen mode

Now we can log in again using an example.com email address, and we'll skip over Osso's hosted login page. The downside of this approach is that Osso will display an error if there is not a SAML configured customer for the supplied domain.

To best serve our SAML SSO users, we'd only send users who belong to an onboarded SAML customer to Osso, and we wouldn't require a user to remember that they use SAML SSO to sign in. Unfortunately this issue has become incredibly fraught. Non-SAML users hate when you split a login form into two steps, and they usually have no idea why anyone would implement such a poor UX. There's no perfect solution if we want to serve all of our users well, just the least bad solution. We'll do our best to make our login flow frustrate the least number of users — engineering is, after all, about tradeoffs.

We should also aim to reduce the amount of code you need to write to integrate Osso - you shouldn't have to persist in your database whether one of your tenants uses SAML. A user should just be able to come to your login page and login, whatever mechanism their account is set up to use. Yes, we want to save you effort, but we also want to keep things simple, direct and easy to reason about.

Osso also offers a React library that provides components like an <OssoLogin /> form as well as lower level hooks for interacting with your Osso instance. Rather than you persisting data about which of your customers use SAML for auth, you can use the Osso React library to talk directly to Osso in order to build out your login form. Of course if you're not already using React on your front end it won't make a ton of sense to take this approach, but the Osso React source code should help you understand how you can implement such a form yourself.

We'll skip past a bit of Rails and React setup - the result PR will show everything you need to do to get React set up with webpacker, but let's assume that we've got a JSX file we can treat as a Javascript pack that includes React and renders the React app to the DOM. We'll reuse the markup from previous steps to start building our login form.

import React from 'react'
import ReactDOM from 'react-dom'

const App = () => (
  <div className="container">
    <div className="main-content login">
      <h1>Welcome!</h1>    
    </div>
  </div>
)

document.addEventListener('DOMContentLoaded', () => {
  ReactDOM.render(
    <App />,
    document.body.appendChild(document.createElement('div')),
  )
})

Enter fullscreen mode Exit fullscreen mode

We can install Osso's React library from npm:

$ yarn add @enterprise-oss/osso
Enter fullscreen mode Exit fullscreen mode

And we need to wrap any of our Osso components in an <OssoProvider />, passing a baseUrl to the client options. We're still using the Osso Demo instance here, which is an anything goes environment. If you're using your own instance, you'll need to set the CORS_ORIGINS ENV var to the origin where you're using this form.

import { OssoLogin, OssoProvider } from '@enterprise-oss/osso';

const App = () => (
  <OssoProvider
    client={{
      baseUrl: 'https://demo.ossoapp.com',
    }}
  >
    <div className="container">
      <div className="main-content login">
        <h1>Welcome!</h1>
      </div>
    </div>
  </OssoProvider>
)
Enter fullscreen mode Exit fullscreen mode

Then we can add the <OssoLogin /> component - we'll review the props afterwards.

const App = () => (
  <OssoProvider
    client={{
      baseUrl: 'https://demo.ossoapp.com',
    }}
  >
    <div className="container">
      <div className="main-content login">
        <h1>Welcome!</h1>
        <OssoLogin
          ButtonComponent={Button}
          InputComponent={Input}
          containerClass="login-form"
          onSamlFound={submitSaml}
          onSubmitPassword={onSubmitPassword}
        />
      </div>
    </div>
  </OssoProvider>
)
Enter fullscreen mode Exit fullscreen mode

The OssoLogin component displays an email input and submit button, and on submit, the component talks directly to your Osso instance to determine if that user can sign in using SAML via Osso. Osso's components are typically "headless" - they allow you to provide your own UI components to match the rest of your application, and surround them with some logic. Check out CodeSandboxes for Ant Design and Material UI

For our purposes, let's create our own basic components to use here:

  const Button = props => (
    <button {...props} />
  )

  const Input = ({ onChange, ...props}) => (
    <>
      <label htmlFor={props.id}>{props.label}</label>
      <input 
        {...props}
        // Osso expects a value in change handlers rather than events
        onChange={(e) => onChange && onChange(e.target.value)} 
      />
    </>
  )
Enter fullscreen mode Exit fullscreen mode

The event handler props are what makes the form work with your back end authentication. When we do find that a user can log in with SAML via Osso, the login component calls the onSamlFound prop function. We don't want to send users to Osso right away - we need to send them through your back end to protect against CSRF attacks and to properly use our OmniAuth strategy.

So we'll use a similar function to what we've used previously for submitting the SAML sign in form. We need to again grab the authenticity token, and submit a POST request from the browser via a form submit, including the email address for the user:

  const submitSaml = (email) => {
    const csrfToken = document.querySelector('meta[name="csrf-token"]').content
    const form = document.createElement("form");

    const tokenInput = document.createElement("input"); 
    tokenInput.value = csrfToken;
    tokenInput.name = "authenticity_token"

    const emailInput = document.createElement("input"); 
    emailInput.value = email;
    emailInput.name = "email"

    form.action = `/users/auth/osso`;   
    form.hidden = true
    form.method = "POST";

    form.appendChild(tokenInput);  
    form.appendChild(emailInput);  
    document.body.appendChild(form);

    return form.submit();
  }
Enter fullscreen mode Exit fullscreen mode

Any onboarded SAML user is now able to sign in, but we also need to handle non-SAML users. The login component will display a password field, and on a form submit with an email and password, will call the onSubmitPassword function. Without knowing more about your authentication approach, we can't really say how to handle these - you may want to do something similar to the SAML handler if you use session based authentication, or post an ajax request if you use something like JWTs. We'll just show you the function signature and log the values:

  const onSubmitPassword = (email, password) => {
    console.warn(`Submit a request to sign the user in 
      to your server. Email: ${email}, Password: ${password}`);
    return Promise.resolve();
  }
Enter fullscreen mode Exit fullscreen mode

The final diff for integrating Osso into our Rails app can be found here.

Conclusion

If you need to add SAML SSO to your Rails application you should now be able to make an informed decision about how you want to approach this project. If you're working on a multi-tenant application, we've seen that the challenges of releasing a production-ready integration are due to the instance based nature of SAML SSO.

Existing OSS handles a lot of the nitty-gritty of SAML, but without Osso, you're responsible for building a scalable system to onboard new customers. We feel pretty strongly that you need to document this process for your customers, and unless you plan on having an engineer add database rows by hand for each new tenant, you'll also need a UI and CRUD operations for your SAML configurations. You'll need to work through edge cases for various Identity Providers and you'll need to sign up for a few of them yourself to have confidence that you've properly integrated that provider. You'll also need to think through how to approach sign in UX for your SAML users, while still providing a good UX for your non-SAML users.

We hope you'll consider using Osso so we can handle all of these challenges while you focus on features that are more core to your application. We think you'll find our pricing fair, transparent and predictable, and we're also more than happy to help you get going with an open source deployment.

You can reach us via chat, at hello@ossoapp.com, or me personally on Twitter @sammybauch.

Discussion (0)

pic
Editor guide