Sar Yay Club

Do it first. You can do it right later.

02 Jul 2020

Creating SAML IDP provider with Ruby on Rails.

Creating SAML IDP provider with Ruby on Rails.

Rails SAML IDP Provider with devise

Recently I was working on single sign on solution for my company. After researching, we decided to use SAML for authentication. I believe everyone reading this post is already familiar with Rails and SAML. Security Assertion Markup Language, SAML is an open standard for exchanging authentication and authorization data between parties in XML format. It contains identity provider IDP which acts as a centralized authentication services and service providers SP use response XML from IDP to authenticate users. In simpler way, users logged in to IDP will automatically logged in to SP. We will have one Rails SAML identity provider with multiple SAML service providers connecting to it. In this post I will show you how to setup SAML IDP for Rails and shows how SAML SP can connect to it.

We are going to use devise for users in both SAML IDP and SAML SP since its the most common authentication gem for Rails applications.

SAML IDP with devise

1. Installing the Gem

To create Rails application that acts as SAML IDP. I am going to use gem called SAML IDP from https://github.com/saml-idp/saml_idp. You need to add followings to your Gemfile. You need to configure devise in your application. Make sure you have configured a working devise implementation in your Rails app.

gem 'saml_idp'

# Devise
gem 'devise'

You can also use saml_idp gem for non Rails projects. You can find the documentation for non Rails project here. After modifying the Gemfile, lets proceed to install the gems.

bundle install

2. Generating a certificate

To setup an IDP, we need to generate x509 certificate. If you have openssl in you machine, using this command will generate a new certificate.

openssl req -x509 -sha256 -nodes -days 3650 -newkey rsa:2048 -keyout production.key -out production.crt

These keys are important, please keep them in a safe place.

3. Configuration IDP intializer

Ok, we have both key and cert generated in step 2. Lets add them to the configuration

SamlIdp.configure do |config|
  # We stored keys in environment variables.
  config.x509_certificate = ENV["SAML_CERT"]
  config.secret_key = ENV["SAML_SECRET_KEY"]
end

We still have a few configurations that we need to configure. We will come back to this later.

4. Modifying controller.

Create saml_idp_controller.rb in controllers folder and paste this code below

class SamlIdpController < SamlIdp::IdpController
  # Devise authenticate user
  before_action :authenticate_user!, except: [:show]

  # override create and make sure to set both "GET" and "POST" requests to /saml/auth to #create
  def create
    if user_signed_in?
	# renderin xml response in saml format for devise's current_user
      @saml_response = idp_make_saml_response(current_user)
      render :template => "saml_idp/idp/saml_post", :layout => false
      return
    else
    # it shouldn't be possible to get here, but lets render 403 just in case
      render :status => :forbidden
    end
  end

  def idp_make_saml_response(found_user) # not using params intentionally
    encode_response found_user,{}
  end

  private :idp_make_saml_response

end

The code is simple, all it does it if service provider call /saml/auth if user is signed in, it will response user object with SAML XML format, or else it will show devise’s login page.

4. Routes

In routes.rb


get '/saml/auth' => 'saml_idp#create'
post '/saml/auth' => 'saml_idp#create'
get '/saml/metadata' => 'saml_idp#show'

Make sure we are pointing the the same function in both get or post. By default get method to /saml/auth will go to saml_idp#new which will call SamlIdp::IdpController new method. This will render login page from the gem and it will show double login page, both devise login page and SAML login page. Pointing both get and post to saml_idp#create is required for devise based implementations .

5. Detail configurations

Now the only thing we left is configure the user fields as we need.

SamlIdp.configure do |config|
	# Domain of your identity provider. example, https://example.com
  base = ENV["HOST"]

  config.x509_certificate = ENV["SAML_CERT"]
  config.secret_key = ENV["SAML_SECRET_KEY"]
	# Name id formats
  config.name_id.formats = {
      uuid: -> (principal) { principal.id },
      persistent: -> (principal) { principal.email},
      email: -> (principal) { principal.email },
      name: -> (principal) { principal.name },
      department: -> (principal) { principal.department },
      position: -> (principal) { principal.position },
      user_type: -> (principal) { principal.user_type },
      read_only: -> (principal) { principal.read_only },
      uid: -> (principal) { principal.id }
  }

  config.organization_name = "Example"
  config.base_saml_location = "#{base}/saml"
  config.attribute_service_location = "#{base}/saml/attributes"
  config.single_service_post_location = "#{base}/saml/auth"
  config.session_expiry = 0
  config.algorithm = :sha256

  config.attributes = {
    "User id" => {
      "name" => "urn:oid:0.9.2342.19200300.100.1.1",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.id
      },
    }
    "Email address" => {
      "name" => "email",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.email
      },
    },
    "Name" => {
      "name" => "name",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.name
      }
    },
    "Department" => {
      "name" => "department",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.department
      }
    },
     "Position" => {
      "name" => "position",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.position
      }
    },
     "User Type" => {
       "name" => "user_type",
       "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
       "getter" => ->(principal) {
         principal.user_type
       }
     },
     "Read Only" => {
       "name" => "read_only",
       "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
       "getter" => ->(principal) {
         principal.read_only
       }
     }

  }

  service_providers = {
    "serviceprovider.example.com" => {
      fingerprint: "<fingerprint of certificate>",
      metadata_url: "https://serviceprovider.example.com/users/saml/metadata",
      response_hosts: ["serviceprovider.example.com"]
    },
 }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # settings is an IncomingMetadata object which has a to_h method that needs to be persisted
  config.service_provider.metadata_persister = ->(identifier, settings) {
    fname = identifier.to_s.gsub(/\/|:/,"_")
    `mkdir -p #{Rails.root.join("cache/saml/metadata")}`
    File.open Rails.root.join("cache/saml/metadata/#{fname}"), "r+b" do |f|
      Marshal.dump settings.to_h, f
    end
  }

  # `identifier` is the entity_id or issuer of the Service Provider,
  # `service_provider` is a ServiceProvider object. Based on the `identifier` or the
  # `service_provider` you should return the settings.to_h from above
  config.service_provider.persisted_metadata_getter = ->(identifier, service_provider){
    fname = identifier.to_s.gsub(/\/|:/,"_")
    `mkdir -p #{Rails.root.join("cache/saml/metadata")}`
    full_filename = Rails.root.join("cache/saml/metadata/#{fname}")
    if File.file?(full_filename)
      File.open full_filename, "rb" do |f|
        Marshal.load f
      end
    end
  }

  # Find ServiceProvider metadata_url and fingerprint based on our settings
  config.service_provider.finder = ->(issuer_or_entity_id) do
   service_providers[issuer_or_entity_id]
  end
end

Voila. we now have a working Ruby on Rails + devise SAML IDP application. We will continue to create a service provider application that will connect to the current SAML IDP.