Search code examples
devisesingle-sign-onsaml-2.0pingfederateonelogin

Rails/Devise/SAML Metadata Incorrect (not working with PingFederate)


Let me preface this question by saying that I'm new to SAML and barely understand how it works.

The Setup

I'm using the devise_saml_authenticatable gem with a Rails 4 app to achieve SSO. The Rails app acts as the service provider (SP). To test my setup, I created a OneLogin developer account and set up a SAML Test Connector (IdP w/attr w/ sign response) using the following attributes:

Configuration Tab

SSO Tab

  • Issuer URL: https://app.onelogin.com/saml/metadata/589819
  • SAML 2.0 Endpoint (HTTP): https://mysubdomain.onelogin.com/trust/saml2/http-post/sso/589819
  • SLO Endpoint (HTTP): https://mysubdomain.onelogin.com/trust/saml2/http-redirect/slo/589819
  • SAML Signature Algorithm: SHA-1
  • SHA Fingerprint: 60:9D:18:56:B9:80:D4:25:63:C1:CC:57:6D:B9:06:7C:78:BB:2C:F1
  • X.509 Certificate:

    -----BEGIN CERTIFICATE----- MIIEFzCCAv+gAwIBAgIUQYRVa1MQpUh0gJaznmXSF/SPqnowDQYJKoZIhvcNAQEF BQAwWDELMAkGA1UEBhMCVVMxETAPBgNVBAoMCEZpcm1QbGF5MRUwEwYDVQQLDAxP bmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxvZ2luIEFjY291bnQgOTI1MzEwHhcN MTYwOTIxMTU0NzQwWhcNMjEwOTIyMTU0NzQwWjBYMQswCQYDVQQGEwJVUzERMA8G A1UECgwIRmlybVBsYXkxFTATBgNVBAsMDE9uZUxvZ2luIElkUDEfMB0GA1UEAwwW T25lTG9naW4gQWNjb3VudCA5MjUzMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC AQoCggEBALGVgocBj0ciHM3uKlWIcofPhOtzfJw1XpAdNynAvPtbCl7WE5+sLBoQ ZF+oZ7Dl+wRW6DHMJCl9DdKcOaQA6/gr5bwt78IzZ8hWMoKQEPih+E0km6rKLYA8 M52vxtJxGs8Iqx60QvPEePQFMOA+xg73OExfM7W5LnXwNz/Pxgsr3lBif5oCC76j SaTCFroV+TSjfOaYMW/lZrsS79KRIzA9I5XwUBe3bC8bsfQmZXgddCrkQUNSGGaS 7/jtFUlQ94+lAL+l3yoAiNAE6+mt48qqmyLfkKibXvnZ8dwuO272wpY4fEM+vFRy pYrTajqvhY3hYIq8dLw3ominE5VECl8CAwEAAaOB2DCB1TAMBgNVHRMBAf8EAjAA MB0GA1UdDgQWBBSxiuvTPxwOhh2pupID+tuyKCeceTCBlQYDVR0jBIGNMIGKgBSx iuvTPxwOhh2pupID+tuyKCeceaFcpFowWDELMAkGA1UEBhMCVVMxETAPBgNVBAoM CEZpcm1QbGF5MRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxHzAdBgNVBAMMFk9uZUxv Z2luIEFjY291bnQgOTI1MzGCFEGEVWtTEKVIdICWs55l0hf0j6p6MA4GA1UdDwEB /wQEAwIHgDANBgkqhkiG9w0BAQUFAAOCAQEAYBe+5d3zpLZ7fcf3l3rXYeIxcpN+ 9D2YZCbxsrBhY2Am4YE9nN+RaJXeDqeRBNtpayCZVxfHnXexRo1n7wxwTmosiydi 9yE7SY2xZf+3feQreF25atnn4tzVhxYONaX1njZMIt/TNa7A9aeDfHSD+vwSuYYB hGxKT6HOkEAEBiXCZ/FcVNiB0D8bRwQhiJ3BTzXDfqHrmq8QYdn3Ejlqo62vMl6W XeMXUoyv6cUc64Ap6E+XtEQI1E8YB5R8GtTs3Y1Oa2dD6yWyCyVJ20+Hi7IWAqXC EfqstqXB7FoQ2rAt39cepnu1SOarvEYDMwYIaVNF3hoyodBybJJsAwAnCQ== -----END CERTIFICATE-----

In my devise.rb I have the following configuration:

config.saml_create_user = false
config.saml_update_user = true
config.saml_default_user_key = :email
config.saml_session_index_key = :session_index
config.saml_use_subject = true
config.idp_settings_adapter = IdPSettingsAdapter
config.idp_entity_id_reader = DeviseSamlAuthenticatable::DefaultIdpEntityIdReader

Here is my IdPSettingsAdapter:

class IdPSettingsAdapter
    def self.settings(idp_entity_id)
        company = Company.find_by(idp_entity_id: idp_entity_id)

        if company.present?
            {
                assertion_consumer_service_url: company.assertion_consumer_service_url,
                assertion_consumer_service_binding: company.assertion_consumer_service_binding,
                name_identifier_format: company.name_identifier_format,
                issuer: company.issuer,
                idp_entity_id: company.idp_entity_id,
                authn_context: company.authn_context,
                idp_slo_target_url: company.idp_slo_target_url,
                idp_sso_target_url: company.idp_sso_target_url,
                idp_cert_fingerprint: company.idp_cert_fingerprint
            }
        else
            {}
        end
    end
end

Note that my user model Contact belongs_to Company, and that the SSO settings are stored in the Company model.

Here are my saml routes:

devise_for :contacts, skip: :saml_authenticatable, controllers: {
    registrations: "registrations",
    sessions: "sessions",
    passwords: "passwords",
    confirmations: "confirmations"
}
devise_scope :contact do
    get '/sign_in' => 'sessions#new'
    get '/sign_out' => 'sessions#destroy'

    # SSO Routes
    get 'saml/sign_in' => 'saml_sessions#new', as: :new_user_sso_session
    post 'saml/auth' => 'saml_sessions#create', as: :user_sso_session
    get 'saml/sign_out' => 'saml_sessions#destroy', as: :destroy_user_sso_session
    get 'saml/metadata' => 'saml_sessions#metadata', as: :metadata_user_sso_session
    match 'saml/idp_sign_out' => 'saml_sessions#idp_sign_out', via: [:get, :post]
end

Lastly here is my SamlSessionsController:

require "ruby-saml"

class SamlSessionsController < SessionsController
    include DeviseSamlAuthenticatable::SamlConfig
    skip_before_filter :verify_authenticity_token, raise: false
    before_action :authorize_viewer, except: [:metadata]
    protect_from_forgery with: :null_session, except: :create

    def new
        idp_entity_id = Company.friendly.find(@_request.env['HTTP_HOST'].split('.')[0]).idp_entity_id
        request = OneLogin::RubySaml::Authrequest.new
        action = request.create(saml_config(idp_entity_id))
        redirect_to action
    end

    def metadata
        idp_entity_id = Company.friendly.find(@_request.env['HTTP_HOST'].split('.')[0]).idp_entity_id
        meta = OneLogin::RubySaml::Metadata.new
        render :xml => meta.generate(saml_config(idp_entity_id)), content_type: 'application/samlmetadata+xml'
    end

    def create
        @idp_entity_id = Company.friendly.find(@_request.env['HTTP_HOST'].split('.')[0]).idp_entity_id
        response = OneLogin::RubySaml::Response.new(params[:SAMLResponse], settings: saml_config(@idp_entity_id))

        if !response.is_valid?
            puts "SAML FAILED WITH ERROR: "
            puts response.errors
        end
        super
    end

    def idp_sign_out
        company = Company.friendly.find(request.subdomain.downcase)
        idp_entity_id = Company.friendly.find(@_request.env['HTTP_HOST'].split('.')[0]).idp_entity_id

        if params[:SAMLRequest] && Devise.saml_session_index_key
            saml_config = saml_config(idp_entity_id)
            logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest], settings: saml_config(idp_entity_id))
            resource_class.reset_session_key_for(logout_request.name_id)

            # binding.pry
            sign_out current_contact if contact_signed_in?
            redirect_to company.after_slo_url.present? ? company.after_slo_url : 'https://' + company.issuer
            # redirect_to generate_idp_logout_response(saml_config(idp_entity_id), logout_request.id)
        elsif params[:SAMLResponse]
            #Currently Devise handles the session invalidation when the request is made.
            #To support a true SP initiated logout response, the request ID would have to be tracked and session invalidated
            #based on that.
            if Devise.saml_sign_out_success_url
                redirect_to Devise.saml_sign_out_success_url
            else
                redirect_to action: :new
            end
        else
            head :invalid_request
        end
    end

    protected

    # Override devise to send user to IdP logout for SLO
    def after_sign_out_path_for(_)
        request = OneLogin::RubySaml::Logoutrequest.new
        request.create(saml_config)
    end

    def generate_idp_logout_response(saml_config, logout_request_id)
        OneLogin::RubySaml::SloLogoutresponse.new.create(saml_config, logout_request_id, nil)
    end
end

The Problem

When I manually save map the settings from my OneLogin adapter to my Company model (see screenshot), I'm able to authenticate as a user of my app using OneLogin as the identity provider (IdP). However now I need to provide a client with the XML metadata representing the app's setup. When I go to /saml/metadata.xml, I get the following configuration, which according to my client, is incorrect. The client didn't offer any further details about what the problem is. They are using PingFederate, if that matters.

<?xml version='1.0' encoding='UTF-8'?>
<md:EntityDescriptor ID='_a3581975-b73d-4784-a106-bafd61e15f87' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
    <md:SPSSODescriptor AuthnRequestsSigned='false' WantAssertionsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
        <md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
        <md:AssertionConsumerService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://mysubdomain.myapp.local:3000/saml/auth' index='0' isDefault='true'/>
    </md:SPSSODescriptor>
</md:EntityDescriptor>

enter image description here

My question is, what am I doing wrong here and how can I correct it? As I said, I barely understand how SAML works under the hood.


Solution

  • There is no EntityID defined on that metadata XML. If you try to verify the XML on a validation tool you will get

    Line: 2 | Column: 0 --> Element '{urn:oasis:names:tc:SAML:2.0:metadata}EntityDescriptor': The attribute 'entityID' is required but missing.

    If you review ruby-saml code, the EntityID is added to the metadata XML if a settings.issuer is defined. Can you verify if that data is provided? Maybe company.issuer that I see at IdPSettingsAdapter class has an empty value.