How to setup simple multi-auth for SAML and OAuth2 using Rails and Devise

When building web applications, a common requirement is to implement an authentication system. In the vast universe of web development, there are numerous ways to authenticate users; however, two of the most widely used technologies are OAuth2 and SAML. In the Ruby on Rails ecosystem, the Devise gem is the most popular solution for all things authentication, providing a highly flexible and powerful tool for developers' needs. This article focuses on setting up a simple but robust multi-authentication system using OAuth2 and SAML with Rails and Devise. From understanding the core concepts of each technology to overcoming various hurdles in the configuration and setup process, we'll cover everything you need to smoothly integrate these critical systems into your Rails application.

What is OAuth2?

OAuth2, which stands for Open Authorization version 2, is a protocol that allows a user's account information to be used by third-party services, such as Facebook, without revealing the user's password. OAuth2 provides authorized access to user data while protecting the user's credentials. It is often used as a way for users to log in to other trusted services such as Google, Facebook, or GitHub.
OAuth2 primarily provides a process for end-users to authorize third-party access to their server resources without sharing their credentials (typically a username and password pair). It allows certain data to be shared with an application while keeping the user's credentials private.
It's important to note that OAuth2 is not a secure way to authenticate a user, but rather a standard authorization protocol. It can be used in conjunction with technologies such as OpenID Connect to achieve authentication.

What is SAML?

SAML, which stands for Security Assertion Markup Language, is an open standard protocol for handling authentication and authorization between an Identity Provider (IdP) and a Service Provider (SP).
SAML is an XML-based standard for communicating authentication credentials between two parties. In a SAML flow, the Identity Provider (IdP) provides the Service Provider (SP) with a signed XML document containing a variety of identity assertion information that validates and authenticates a user.
SAML is most commonly used in enterprise business tools, primarily for single sign-on (SSO) scenarios. This one-click user experience improves productivity because users don't have to remember multiple username/password combinations and aren't interrupted by multiple login prompts when moving between enterprise applications.
With SAML authentication, the identity provider performs the authentication process and sends the SAML assertion document to the service provider. However, the service provider parses the SAML assertion document, verifies it by checking the identity provider's digital signature, extracts the user profiles and attributes, and then authorizes the user based on those attributes.

What is Identity Provider (IdP) and Service Provider in SAML?

The identity provider is a service that provides the account information. The service provider is your application.

How to set up a sample OAuth2 app via Google?

First, make sure you're signed in to your Google Account. Create a new project in the Google Cloud Resource Manager (https://console.cloud.google.com/cloud-resource-manager). Navigate to the 'OAuth consent screen' section. Select either 'Internal' or 'External', then select 'Create'.

Fill in the information on the next screen. This includes the name of the application and your email address. This email will be the primary contact for the developers. After filling in the details, click "Save and Continue".

On the next screen, click 'Add Resources'. Then select auth/userinfo.email for our minimal setup. Then click 'Update'.

To add users who can log in to your app, use your Google email account for now.

Go to the Credentials section at Google Cloud Credentials (https://console.cloud.google.com/apis/credentials) and click 'Create Credentials'. Then, select 'Create OAuth client ID'.

Create a name and add the Authorized URL in the edit section: http://your-app-domain.com:3000/users/auth/google_oauth2/callback

Click 'Save' and you will be given a client ID and a client secret. Both are required for integration with your Rails application.

How to set up a sample SAML app via OneLogin?

Sign up for an account with OneLogin. Navigate to Applications and locate 'SAML Custom Connector'. Click on the connector and give it a unique name. Once created, go to the Configuration tab and fill in the required fields.

The URL should match your application's domain. For example, if you're using a local machine, enter something like http://192.168.31.43.nip.io:3000. The rest of the URL will probably stay the same as on the image unless you change the device configuration routing.

Next, you'll need specific values from your identity provider, which can be obtained from the XML provided. Be sure to copy and save the link; it will be needed to set details such as idp_slo_service_url, idp_cert, idp_sso_service_url, etc.

Finally, go to your profile section and assign access to your newly created application. This will allow your account to log in. To assign access, navigate to Users, select your user, go to the Applications tab on the left, and click the "+" icon at the top right. Then select your application and click "Next".

How to set up a sample SAML app via Okta?

After creating your account, navigate to the admin/apps/active subpage and click on 'Create App Integration'. Select 'SAML 2.0' and continue.

Enter a unique name for your application in the App Name field and proceed to the next step.

Next, enter your single sign-on URL. This could be something like http://your-sample-domain.nip.io:3000/users/auth/saml/callback. Make sure you check the 'Use this for the recipient URL and destination URL' box. The recipient URI is the identifier for your application, you can enter anything that allows you to easily identify it. Also, make sure that the 'Name ID format' is set to EmailAddress.

Click 'Next' and select 'I'm an Okta customer adding an internal app' and 'This is an internal app that we have created'. Then, click 'Finish'.

Congratulations on your success! You now have a critical component - the MetadataURL. It gives you access to the information you need to configure your Rails application later. Make sure you save this link somewhere safe.

Go back to the list of applications, click the gear icon, and select "Assign to Users. Then select the user who will have access to this app. This will allow you to use the credentials from your primary Okta account in your Rails app later.

Integrating Google multi-auth in Rails with Devise

Assuming you already have a Ruby on Rails application and Devise installed, the next step is to integrate some gems to support Omniauth.

gem 'omniauth-google-oauth2', '1.1.1'
gem 'omniauth-rails_csrf_protection', '1.0.1'

Then run bundle install. Now update your Devise routes in your routes.rb file.

# routes.rb
  devise_for :users,
             controllers: {
               omniauth_callbacks: 'users/omniauth_callbacks',
             }

After setting up the routes, add a OmniauthCallbacksController.

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token

  def google_oauth2
    user = User.find_by(email: google_oauth_email)
    if user.present?
      sign_in_and_redirect_user(user, 'Google')
    else
      flash[:alert] = t('devise.omniauth_callbacks.failure', kind: 'Google', reason: "#{google_oauth_email} is not authorized.")
      redirect_to after_omniauth_failure_path_for
    end
  end

  private

  def google_oauth_email
    request.env['omniauth.auth']&.info&.email
  end

  protected

  def after_omniauth_failure_path_for(_scope_resource = nil)
    root_path
  end

  def after_sign_in_path_for(_scope_resource = nil)
    root_path
  end
end

The controller will process the information returned by Google and try to retrieve a user by email. If a user is found, it will redirect to the root_path. If not, it will display an error and redirect to the login page. All the information returned can be accessed via request.env['omniauth.auth'].
Now, let's make sure Devise knows that we want to use Omniauth for Google. Update your user model accordingly.

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         # add omniauthable
         :omniauthable, omniauth_providers: %i[google_oauth2]
end

Indicate to Devise that the user model is :omniauthable. We also need to pass the previously saved client_id and client_secret. To accomplish this, create a new file named devise_multi_auth.rb in config/initializers/.

# config/initializers/devise_multi_auth.rb
class BaseMultiAuthSetup
  def self.call(env)
    new(env).setup
  end

  def initialize(env)
    @env = env
  end

  def setup
    @env['omniauth.strategy'].options.merge!(omniauth_strategy_settings)
  end
end

class GoogleOauth2Setup < BaseMultiAuthSetup
  def omniauth_strategy_settings
    # You can add your own logic here, e.g. set options based on URL/params/etc.
    # request.params['saml_idp'] == 'idp1' ? idp1_settings : idp2_settings
    # request.subdomain == 'myprojectname1' ? { client_id: '1', client_secret: '2' } : { client_id: '3', client_secret: '4' }
    #
    # You can find this data in your Google API Console at https://console.cloud.google.com/apis/credentials.
    {
      client_id: '400905005724-h150pkaaaanjt4.apps.googleusercontent.com',
      client_secret: 'GOCSPX-0wfbbbbbbbbbbb8boZdNZsZ_e'
    }
  end
end

Devise.setup do |config|
  config.omniauth :google_oauth2, setup: GoogleOauth2Setup
end

This file gives you complete control over what data is routed under GoogleOauth2Setup#omniauth_strategy_settings. You can simply add a hash object with the required data. Alternatively, you can add custom logic based on parameters. See the comments for examples.
As an additional step, let's add a login button. Make sure the button has turbo:false to avoid CORS issues. To do this, we will create a session.html.erb file.

<!-- views/devise/sessions/new.html.erb -->
<h2>Log in</h2>

<form method="post" action="<%= user_google_oauth2_omniauth_authorize_path %>" data-turbo="false">
  <input type="hidden" name="authenticity_token" value=<%= form_authenticity_token %>>
  <button type="submit">Login via Google OAuth2</button>
</form>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "current-password" %>
  </div>

  <% if devise_mapping.rememberable? %>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end %>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

Update your devise/shared/links.html.erb file to prevent path-related errors.

<!-- views/devise/shared/links.html.erb -->

<%- if controller_name != 'sessions' %>
  <%= link_to "Log in", new_session_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
  <%= link_to "Sign up", new_registration_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
  <%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
  <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
<% end %>

<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
  <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
<% end %>

That's all it takes for successful authentication via a Google account! Run rails s and navigate to the login page to sign in using Google.

How to solve domain problems under development stage?

You'll notice in the screenshots that I'm using a .nip.io domain, which acts as a proxy for my local development environment. If you prefer, you can just use http://localhost:3000. However, if you want to use a domain that supports subdomains and other features for your local machine, you can learn how to do that by reading this article.

Integrating SAML multi-auth in Rails with Devise

Start by adding a new gem to your Gemfile.

gem 'devise', '4.9.3'
gem 'omniauth-google-oauth2', '1.1.1'
gem 'omniauth-saml', '2.1.0' #gem that support SAML protocol
gem 'omniauth-rails_csrf_protection', '1.0.1'

Execute bundle install. Next, navigate to the recently created callbacks controller and add a new method for SAML:

# app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  skip_before_action :verify_authenticity_token

  def google_oauth2
    # ...
  end

  def saml
    user = User.find_by(email: saml_data_response.uid)

    # create user if not exists
    # you can add additional check based on the group name/etc. (saml_data_response_group_name) passed by IdProvider
    if user.nil?
      user = User.create!(
        email: saml_data_response.uid,
        password: SecureRandom.hex(16),
        # role_group: saml_data_response_group_name,
        # first_name: saml_data_response.info.first_name,
        # last_name: saml_data_response.info.last_name
      )
    end

    sign_out_all_scopes
    flash[:success] = t('devise.omniauth_callbacks.success', kind: 'SAML')
    sign_in_and_redirect user, event: :authentication
  end

  private

  def google_oauth_email
    request.env['omniauth.auth']&.info&.email
  end

  def saml_data_response
    request.env['omniauth.auth']
  end

  def saml_data_response_group_name
    # you can fetch any specified attribute from your IdP
    saml_data_response.extra.response_object.attributes['group']
  end

  protected

  def after_omniauth_failure_path_for(_scope_resource = nil)
    root_path
  end

  def after_sign_in_path_for(_scope_resource = nil)
    root_path
  end
end

This newly added saml method will capture all the data passed by our identity provider. First, it tries to find a user using the saml_data_response.uid passed. If a user is found, the user will be redirected to root_path. If the user is not found, a new user is created based on the data provided by the identity provider.
Now let's notify Devise that you're integrating SAML support. Visit your user.rb model to do so:

# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: %i[google_oauth2 saml] # support google_oauth2 and saml
end

Luckily, we won't need to update routes.rb because the necessary routing for Omniauth was added during the Google auth setup. Next add a configuration file to provide the necessary SAML configuration details:

# config/initializers/multi_auth_devise.rb
class BaseMultiAuthSetup
  def self.call(env)
    new(env).setup
  end

  def initialize(env)
    @env = env
  end

  def setup
    @env['omniauth.strategy'].options.merge!(omniauth_strategy_settings)
  end

  def omniauth_strategy_settings
    raise NotImplementedError
  end
end

class SamlMultiAuthSetup < BaseMultiAuthSetup
  def omniauth_strategy_settings
    # You can add your own logic here, e.g. set options based on URL/params/etc.
    # request.params['saml_idp'] == 'idp1' ? idp1_settings : idp2_settings
    # request.subdomain == 'myprojectname1' ? { aa: 1 } : { aa: 2 }

    {
      # You can find this data in your IdProvider's metadata XML file. You can retrieve these missing details using that code:
      # your_issuer_url_xml = 'https://app.onelogin.com/saml/metadata/21bf2959-9dbb-49f1-8f8f-4e246bc0cfd8'
      # data = OneLogin::RubySaml::IdpMetadataParser.new.parse_remote(your_issuer_url_xml)
      # data.idp_sso_service_url
      #
      # Each field is described in detail here: https://github.com/omniauth/omniauth-saml#options
      # idp_slo_service_url: 'https://myappnamedemo-dev.onelogin.com/trust/saml2/http-redirect/slo/2687489', # This field is not required, but it is nice to support it.
      idp_cert_fingerprint: '17:FE:F0:46:65:43:15:69:5F:A5:C0:00:56:6A:50:45:83:B4:63:54', # SHA1 fingerprint of the IdP signing certificate
      idp_sso_service_url: "https://dev-93536331.okta.com/app/dev-93536331_mydemosample12_1/exkceus6zpxWJI2I75d7/sso/saml",

      # This data is configured by you (here, in this file).
      sp_entity_id: 'your-appname',
      allowed_clock_drift: 5,
      slo_default_relay_state: "/users/sign_in",
      request_attributes: [:name, :first_name, :last_name, :email],
      name_identifier_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
      single_logout_service_url: "http://192.168.1.45.nip.io:3000/users/auth/saml/slo",
      assertion_consumer_service_url: 'http://192.168.1.45.nip.io:3000/users/auth/saml/callback',
      # skip_recipient_check: true, # specified recipient URL(same as assertion_consumer_service_url) in your IdProvider | default: false
    }
  end
end

class GoogleOauth2Setup < BaseMultiAuthSetup
  def omniauth_strategy_settings
   # ...
  end
end

Devise.setup do |config|
  config.omniauth :saml, setup: SamlMultiAuthSetup
  #config.omniauth :google_oauth2, setup: GoogleOauth2Setup
end

Under SamlMultiAuthSetup#omniauth_strategy_settings, you need to specify any missing credentials. If you saved the Issue URL from OneLogin or the Metadata URL from Okta, now is the time to add these details. You can do this using the Rails console.

your_issuer_url_xml = 'https://app.onelogin.com/saml/metadata/11bf2959-9dbb-49f1-8f8f-33333'
data = OneLogin::RubySaml::IdpMetadataParser.new.parse_remote(your_issuer_url_xml)
data.idp_sso_service_url #=> "https:/...."
data.idp_cert_fingerprint #=> "AA:BB.."

Use the console to update the missing credentials. Make sure the URL domains are updated correctly. Now add a button to your login form:

<!-- views/devise/sessions/new.html.erb -->
<h2>Log in</h2>

<form method="post" action="<%= user_google_oauth2_omniauth_authorize_path %>" data-turbo="false">
  <input type="hidden" name="authenticity_token" value=<%= form_authenticity_token %>>
  <button type="submit">Login via Google OAuth2</button>
</form>
<br>
<form method="post" action="<%= user_saml_omniauth_authorize_path %>" data-turbo="false">
  <input type="hidden" name="authenticity_token" value=<%= form_authenticity_token %>>
  <button type="submit">Login via SAML</button>
</form>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
  </div>

  <div class="field">
    <%= f.label :password %><br />
    <%= f.password_field :password, autocomplete: "current-password" %>
  </div>

  <% if devise_mapping.rememberable? %>
    <div class="field">
      <%= f.check_box :remember_me %>
      <%= f.label :remember_me %>
    </div>
  <% end %>

  <div class="actions">
    <%= f.submit "Log in" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

To support logging out of the Identity Provider, update the destroy action for the Devise SessionsController. Here's a sample implementation:

# controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
  def destroy
    # save the saml_uid and saml_session_index in the session when destroying directly from SAML IdProvider
    saml_uid = session['saml_uid']
    saml_session_index = session['saml_session_index']

    super do
      session['saml_uid'] = saml_uid
      session['saml_session_index'] = saml_session_index
    end
  end

  protected

  def after_sign_out_path_for(_resource_or_scope)
    if session['saml_uid'] && session['saml_session_index'] # && saml_config.idp_slo_service_url.present? # make sure 'idp_slo_service_url' is configured!
      user_saml_omniauth_authorize_path + '/spslo'
    else
      new_user_session_path
    end
  end
end

When a user logs out, the system stores the user's SAML unique identifier (saml_uid) and SAML session index (saml_session_index) from the session before executing the destroy method of the parent class.
The after_sign_out_path_for method determines the post-logout redirect for the user. If the destroy method preserved SAML information in the session and the Identity Provider (IdP) is configured to handle the Single Logout (SLO) service, the user is redirected to perform a SAML logout. Otherwise, the user is redirected to the login form.
In essence, this code allows for integrated logout processes where logging out from your application also logs the user out from the SAML identity provider.

How to solve CORS problems when logging/out via OAuth and SAML?

To troubleshoot CORS issues with OAuth or SAML login/logout, consider whether your application uses Turbo. If it does, make sure to disable Turbo for the form. You can do this by adding data-turbo="false" or data: { turbo: false } to your form configuration. This change will prevent Turbo from processing your form, thus avoiding potential CORS-related problems.

<!-- login page -->
<form method="post" action="<%= user_google_oauth2_omniauth_authorize_path %>" data-turbo="false">
  <input type="hidden" name="authenticity_token" value=<%= form_authenticity_token %>>
  <button type="submit">Login via Google OAuth2</button>
</form>
<br>
<form method="post" action="<%= user_saml_omniauth_authorize_path %>" data-turbo="false">
  <input type="hidden" name="authenticity_token" value=<%= form_authenticity_token %>>
  <button type="submit">Login via SAML</button>
</form>

Summary

I hope this article helps you get started with OAuth2 and SAML authentication. For a complete Rails implementation example, you can check out this repository. You might want to check out the commits to see how the setup was done.

Repository with example implementation: https://github.com/Oxyconit/multiauthappdevise

Happy coding!