How to use gem omniauth and omniauth-oauth2 to implement SSO for multiple customers

michymono77

Michiharu Ono

Posted on December 1, 2024

How to use gem omniauth and omniauth-oauth2 to implement SSO for multiple customers

I recently had the opportunity to set up SSO in a Ruby on Rails app for a very unique situation, so I decided to write about my experience.

This guide might be helpful to you if:

  • You plan to use OpenID Connect as the authentication protocol.
  • You need to implement SSO for multiple customers, and at least two of them use the same identity provider (IdP), such as Okta.
  • Your Rails setup does not support dynamically configuring client information as described in other solutions.
  • Due to customer-side limitations, their IdP CANNOT send custom parameters back to your Rails application. (*Most of the time, they should be able to do so. )
  • You cannot use third-party tools like Keycloak because of resource constraints or compliance requirements prohibiting external IAM solutions.

Suppose you have to implement SSO for two customers that use Okta for identity management, given the limitation above, there is a walk around😃

Let's dive into the actionable steps to implement SSO in your Ruby on Rails app while addressing the limitations described earlier!

Step 1: Add the required gems and run bundle install

# Gemfile
gem 'devise'
gem 'omniauth'
gem 'omniauth-rails_csrf_protection' # NOTE: Required as a countermeasure for CVE-2015-9284 in the omniauth gem.
gem 'omniauth-oauth2' # NOTE: Used to create Strategies classes for omniauth.
Enter fullscreen mode Exit fullscreen mode

Step 2: Define the endpoints.

# config/routes.rb 
get 'auth/customer_a/callback', to: 'omniauth/sessions#create'
get 'auth/customer_b/callback', to: 'omniauth/sessions#create'
Enter fullscreen mode Exit fullscreen mode

Step 3: Add a migration file and run rails db:migrate.

# db/migrate/xxxx.rb
class AddOmniauthColumnsToUsers < ActiveRecord::Migration[7.2]
  def change
    add_column :users, :provider, :string
    add_column :users, :uid, :string
  end
end
Enter fullscreen mode Exit fullscreen mode

Step 4: Add the self.from_omniauth(auth) method as a class method in the User model to create or find a user based on authentication data from an external service. (Ensure to include error handling tailored to your app's requirements.)

 # app/models/user.rb
 class User < ApplicationRecord
    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :encryptable
    ...

  def self.from_omniauth(auth)
    find_or_create_by(email: auth['info']['email']) do |user|
      user.provider = auth['provider']
      user.uid = auth['uid']
      user.email = auth['info']['email']
      user.name = auth['info']['name']
    end
  end
 end
Enter fullscreen mode Exit fullscreen mode

Step 5: Add a controller (Ensure to include error handling tailored to your app's requirements. Your customer could make their mistakes in configuration setup as well.)

*Note: You need the id_token to end an Okta session, so it is recommended to store it in the session. Use the rail’s reset_session helper to remove this when a user wants to log out.

class Omniauth::SessionsController < ActionController::Base
  def create
    omniauth_auth = request.env['omniauth.auth']
    id_token = omniauth_auth['extra']['id_token']
    user = User.from_omniauth(omniauth_auth)
    if user
      session[:id_token] = id_token
      sign_in(:user, user)
      redirect_to root_path
    else
      redirect_to redirect_path, alert: 'Add a custom alert statement here'
    end
  end
Enter fullscreen mode Exit fullscreen mode

Step 6: Create a method to define an OmniAuth OAuth2 strategy for each customer.

Most of the code is from the following gem: omniauth-okta

# config/initializers/omniauth_okta.rb
require 'omniauth-oauth2'

OIDC_DEFAULT_SCOPE = %{openid profile email}.freeze

def create_omniauth_strategy(name)
  Class.new(OmniAuth::Strategies::OAuth2) do

    option :name, name
    option :skip_jwt, false
    option :jwt_leeway, 60

    option :client_options, {
      site:                 'https://your-org.okta.com',
      authorize_url:        'https://your-org.okta.com/oauth2/default/v1/authorize',
      token_url:            'https://your-org.okta.com/oauth2/default/v1/token',
      user_info_url:        'https://your-org.okta.com/oauth2/default/v1/userinfo',
      response_type:        'id_token',
      authorization_server: 'default',
      audience:             'api://default'
    }
    option :scope, OIDC_DEFAULT_SCOPE

    uid { raw_info['sub'] }

    info do
      {
        name:       raw_info['name'],
        email:      raw_info['email'],
        first_name: raw_info['given_name'],
        last_name:  raw_info['family_name'],
        image:      raw_info['picture']
      }
    end

    extra do
      {}.tap do |h|
        h[:raw_info] = raw_info unless skip_info?

        if access_token
          h[:id_token] = id_token

          if !options[:skip_jwt] && !id_token.nil?
            h[:id_info] = validated_token(id_token)
          end
        end
      end
    end

    def client_options
      options.fetch(:client_options)
    end

    def raw_info
      @_raw_info ||= access_token.get(client_options.fetch(:user_info_url)).parsed || {}
    rescue ::Errno::ETIMEDOUT
      raise ::Timeout::Error
    end

    def callback_url
      options[:redirect_uri] || (full_host + callback_path)
    end

    def id_token
      return if access_token.nil?

      access_token['id_token']
    end

    def authorization_server_path
      site = client_options.fetch(:site)
      authorization_server = client_options.fetch(:authorization_server, 'default')
      "#{site}/oauth2/#{authorization_server}"
    end

    def authorization_server_audience
      client_options.fetch(:audience, 'default')
    end

    def validated_token(token)
      JWT.decode(token,
                 nil,
                 false,
                 verify_iss:        true,
                 verify_aud:        true,
                 iss:               authorization_server_path,
                 aud:               authorization_server_audience,
                 verify_sub:        true,
                 verify_expiration: true,
                 verify_not_before: true,
                 verify_iat:        true,
                 verify_jti:        false,
                 leeway:            options[:jwt_leeway]
      ).first
    end
  end
end

okta_sso_customers = ['customer_a', 'customer_b']

okta_sso_customers.each do |name|
  strategy_class = create_omniauth_strategy(name)
  OmniAuth::Strategies.const_set(name.camelize, strategy_class)
end
Enter fullscreen mode Exit fullscreen mode

Step 7: Add omniauth.rb. Use ENV variables and avoid hardcoding secrets in configuration files.

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  provider :customer_a,
        ENV['OKTA_CLIENT_ID'],
    ENV['OKTA_CLIENT_SECRET'],
    client_options: {
      site: ENV['OKTA_ISSUER'],
      authorization_server: ENV['OKTA_SERVER_NAME'], # e.g., 'default'
      authorize_url: "#{ENV['OKTA_ISSUER']}/oauth2/#{ENV['OKTA_SERVER_NAME']}/v1/authorize",
      token_url: "#{ENV['OKTA_ISSUER']}/oauth2/#{ENV['OKTA_SERVER_NAME']}/v1/token",
      user_info_url: "#{ENV['OKTA_ISSUER']}/oauth2/#{ENV['OKTA_SERVER_NAME']}/v1/userinfo",
      audience: ENV['OKTA_AUDIENCE'], # e.g., 'api://default'
     },
    redirect_uri: ENV['OKTA_REDIRECT_URI']

   provider :customer_b,
        ...
end

Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Implementing SSO with the outlined steps makes it easier to set up seamless authentication for multiple customers using OpenID Connect—even in tricky situations like this one! 😊 If you find yourself in a similar boat, think of this guide as your starting template 🛠️, ready to be customized for your specific needs. By using OmniAuth and creating tailored strategies for each identity provider, you’re building a scalable and secure authentication setup that works for everyone involved. 🚀

💖 💪 🙅 🚩
michymono77
Michiharu Ono

Posted on December 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related