How to use gem omniauth and omniauth-oauth2 to implement SSO for multiple customers
Michiharu Ono
Posted on December 1, 2024
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.
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'
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
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
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
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
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
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. 🚀
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
December 1, 2024
September 3, 2024