3 Comments

How to Set Up a Rails SAML Identity Provider

Security Assertion Markup Language (SAML) is a protocol for single sign-on. SAML handles communication between a service provider (SP) and an identity provider (IdP). The identity provider acts as an authentication and identification mechanism for service providers.

In this post, I’ll walk you through setting up your Rails application as a SAML IdP. This post is not intended to take a deep dive into the SAML protocol.

1. Install the Gem

First, you’ll need to add the saml_idp gem to your Gemfile and then run bundle install.

2. Generate a Certificate

In order to set up your IdP, you’ll need to generate a new x509 certificate. If you have openssl installed, you can use the command:

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

Make sure you keep these files in a safe place, particularly myCert.crt.

3. Add Your Certificate Information to the Initializer

If an initializer file was not created when you installed the gem, create one now. The first thing we will add to this file is your new certificate. You can see an example file below. Notice that we do not include the private key; instead, we use an environment variable.


# config/initializers/saml_idp.rb
SamlIdp.configure do |config|
  config.x509_certificate = <<-CERT
  -----BEGIN CERTIFICATE-----
MIIDrzCCApegAwIBAgIJAKg2Off/mPyLMA0GCSqGSIb3DQEBCwUAMEMxCzAJBgNV
BAYTAlVTMREwDwYDVQQIEwhNaWNoaWdhbjEhMB8GA1UEChMYSW50ZXJuZXQgV2lk
Z2l0cyBQdHkgTHRkMB4XDTE3MDUyMjIwNTIxNFoXDTI3MDUyMDIwNTIxNFowQzEL
MAkGA1UEBhMCVVMxETAPBgNVBAgTCE1pY2hpZ2FuMSEwHwYDVQQKExhJbnRlcm5l
dCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
AQDAw2oJbkj27urnCXTqhNvYPQWODZlpfSDD/aCD2BZ7eKylpq9D5LiqsQ98I5aQ
qy4TChbiaZtyiKkURBntcXR6GlhN1bRIyXYmvgwaiEohiH4bd5gsVfdnQWL7yV+R
KGqu8DtVOjJ6bF/V0sjzlYaCKIc+ooUyDdd4SzgYfEhOche5e8Zmn7cREByz7fvq
zEBdX9hMQMn69N4AkY2QSyBRqHmWTUARL5MYDJh3gaX2No8YsC/vhXIdqWLq15xs
ZM0OS82qYsvg/ehTxJXj0mLnHu7iW9PHVQh/GFxLKYc6F9HvQi6a159pi7PbORDc
x6QtzJpAtHZQITJz2NlDxOP7AgMBAAGjgaUwgaIwHQYDVR0OBBYEFBNug68OK8Bn
fKWNq81HZDwg6JgqMHMGA1UdIwRsMGqAFBNug68OK8BnfKWNq81HZDwg6JgqoUek
RTBDMQswCQYDVQQGEwJVUzERMA8GA1UECBMITWljaGlnYW4xITAfBgNVBAoTGElu
dGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAKg2Off/mPyLMAwGA1UdEwQFMAMBAf8w
DQYJKoZIhvcNAQELBQADggEBAAILgqXnnmQtH+7m10oV7MY2n2m8K+l6cd5UfYr2
wRB2+zbvWLjaUIGuRfCePOV5EvTgBCsnoGS8EGx0/8don7G1ktVBteAogEtKaZfn
Yy8PMZlvFCnS2H0mvo//hyNpbWaiHVTKdkFvVjgQmJM0kMzx8t9fJKoZXLZoVYT3
03XBDolj5B5s5jydUdjT7j95gtmNf6sMBLzpkGUJPxtzIPEtqPLrK5oO5zDtTjBd
UhoA3LEbqYPVqohWYb1KU9s3e+egtYpu3phnHvNgxonV3W3f5qB/+EHf8b2ph0It
OXFhQpry40OuTuxqrI8wlI8OBJorCXWy6upWu++OIsLqaUo=
-----END CERTIFICATE-----
  CERT
  config.secret_key = ENV["SAML_SECRET_KEY"]
end

4. Add SAML Routes

You'll need to add a couple of endpoints to your routes file:


# config/routes.rb
get '/saml/auth' => 'saml_idp#new'
post '/saml/auth' => 'saml_idp#create'

5. Create SAML Controller

Now that we added our SAML routes, we need to actually build them. There will be some variation depending on what you're using for authentication. In my example, I'm using devise.


# app/controllers/saml_idp_controller.rb
class SamlIdpController < SamlIdp::IdpController
  before_action :authenticate_user!

  def create
    # The following branch is your point for determining if this user
    # has access to the service
    if user_signed_in? && ThirdPartyModel.find_by(user: user)
      @saml_response = encode_response(current_user)
      render template: "saml_idp/idp/saml_post", layout: false
      return
    else
      redirect_to new_user_session_path
    end
  end
end

The template is provided by saml_idp. It will send a POST request to the service provided with the encoded response.

6. Set Up Service Provider Configuration

The final step is to set up the configuration for your first service provider. Each service provider should have a public URL for viewing its SAML metadata file. I'm not going to go in-depth on all of the contents of the metadata file because saml_idp pushes much of that complexity out of our system.

The three tags we care about in the metadata file are: <ds:X509Certificate>, <md:NameIDFormat>, and <md:RequestedAttribute>.

First, we will set up the general configuration for the SP. This consists of the metadata URL and a fingerprint. You can use this tool to generate a fingerprint from the certificate included in the metadata file.

Then, we will define our NameIDFormat. This tells the service provider where the user's information is stored.

Lastly, we'll configure any RequestedAttributes. When it's all said and done, your initializer will look like this:


SamlIdp.configure do |config|
  base = "http://myapp.com"
  config.x509_certificate = ENV["SAML_IDP_X509_CERTIFICATE"]
  config.secret_key = ENV["SAML_IDP_SECRET_KEY"]

  # NameIDFormat
  config.name_id.formats = {
      persistent: -> (principal) { ThirdPartyModel.find_by(user: principal).external_id }
    }

  config.attributes = {
    "Email address" => {
      "name" => "email",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.email
      },
    },
    "Full name" => {
      "name" => "name",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.name
      }
    },
    "Given name" => {
      "name" => "first_name",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.first_name
      }
    },
    "Family name" => {
      "name" => "last_name",
      "name_format" => "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
      "getter" => ->(principal) {
        principal.last_name
      }
    }
  }

  service_providers = {
    "http://my-service-provider.com" => {
      fingerprint: "D3:D7:B0:DD:AD:1C:E7:A0:A7:C8:F3:D1:97:85:84:16:68:E0:76:28:79:6F:17:5B:A5:7E:E6:9E:3B:ED:51:E2",
      metadata_url: "http://my-service-provider.com/auth/saml/metadata"
    }
  }

 # `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

Debugging

If you run into any issues configuring your IdP or with communication between the IdP and SP, you can try using these tools to help you debug.