The Ruby SAML library is for implementing the client side of a SAML authorization, i.e. it provides a means for managing authorization initialization and confirmation requests from identity providers.
SAML authorization is a two step process and you are expected to implement support for both.
This is the first request you will get from the identity provider. It will hit your application at a specific URL (that you’ve announced as being your SAML initialization point). The response to this initialization, is a redirect back to the identity provider, which can look something like this (ignore the saml_settings method call for now):
def initialize request = Onelogin::Saml::Authrequest.new(settings) # Create the request, returning an action type and associated content action, content = request.create case action when "GET" # for GET requests, do a redirect on the content redirect_to content when "POST" # for POST requests (form) render the content as HTML render :inline => content end end
The create method will choose the appropriate SSO binding that the IdP supports. The “action” here represents a GET or a POST method for the request to the IdP. The content passed back will either be a URL to redirect, or HTML content with a form. (It will submit itself with an onLoad trigger)
Once you’ve redirected back to the identity provider, it will ensure that the user has been authorized and redirect back to your application for final consumption, this is can look something like this (the authorize_success and authorize_failure methods are specific to your application):
def consume response = Onelogin::Saml::Response.new(params[:SAMLResponse]) response.settings = saml_settings if response.is_valid? && user = current_account.users.find_by_email(response.name_id) authorize_success(user) else authorize_failure(user) end end
In the above there are a few assumptions in place, one being that the response.name_id is an email address. This is all handled with how you specify the settings that are in play via the saml_settings method. That could be implemented along the lines of this:
def saml_settings settings = Onelogin::Saml::Settings.new settings.assertion_consumer_service_url = "http://#{request.host}/saml/finalize" settings.issuer = request.host settings.idp_sso_target_url = "https://app.onelogin.com/saml/signon/#{OneLoginAppId}" settings.idp_cert_fingerprint = OneLoginAppCertFingerPrint settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" # Optional for most SAML IdPs settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" settings end
-
Get a copy of the IdP public X.509 certificate
Either get the file itself, or create one by pasting in the contents of the X509Certificate tag out of the metadata or a SAML response. If you paste in the example BEGIN CERTIFICATE and END CERTIFICATE lines exactly as you see them in the example below:
$ cat cert.pem -----BEGIN CERTIFICATE----- MIIBrTCCAaGgAwIBAgIBATADBgEAMGcxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApD YWxpZm9ybmlhMRUwEwYDVQQHDAxTYW50YSBNb25pY2ExETAPBgNVBAoMCE9uZUxv Z2luMRkwFwYDVQQDDBBhcHAub25lbG9naW4uY29tMB4XDTExMTAyNzE5MDAyNloX DTE2MTAyNjE5MDAyNlowZzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3Ju aWExFTATBgNVBAcMDFNhbnRhIE1vbmljYTERMA8GA1UECgwIT25lTG9naW4xGTAX BgNVBAMMEGFwcC5vbmVsb2dpbi5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJ AoGBAMivve9Latml0MJYccayxnXMc5kmSCp8qJZkVnKfei+Dsj0DomzX0iwmuNA4 GwlSiB2DDcImFvldaz/xtyua5D5jFJrplcrM1jIIcHNYwahkRpQQZFYU8wknYZ85 h5+bvkeiM0nLbhhKPRLKCG6f3E5GOM5jVI2sJZA25fZzXEV7AgMBAAEwAwYBAAMB AA== -----END CERTIFICATE-----
-
Use this openssl command line to get the SHA1 fingerprint from the public certificate file:
$ openssl x509 -fingerprint < cert.pem SHA1 Fingerprint=EC:CA:8E:0E:DB:D3:BC:06:9B:1C:1F:3F:42:FE:47:61:0B:DE:91:43
Then assign settings.idp_cert_fingerprint to this value.
The method above requires a little extra work to manually specify attributes about the IdP. (And your SP application) There’s an easier method – use a metadata exchange. Metadata is just an XML file that defines the capabilities of both the IdP and the SP application. It also contains the X.509 public key certificates which add to the trusted relationship. The IdP administrator can also configure custom settings for an SP based on the metadata.
The IdP administrator will give you a URL pointing at his metadata. You need to give him a copy of your metadata as well. Use the Onelogin::Saml::Metadata class to create the XML based on your settings.
def metadata settings = Account.get_saml_settings meta = Onelogin::Saml::Metadata.new render :xml => meta.generate(settings) end
The IdP adminstrator will add this URL on his end, which will poll your URL every few minutes. When you make a change to your local settings, they will propagate to the IdP.
The settings themselves will be a little different. Easier than the method above, as the only required settings are the ACS URL, IdP metadata, and your SP entity ID. (AKA issuer name)
def Account.get_saml_settings # this is just for testing purposes. # should retrieve SAML-settings based on subdomain, IP-address, NameID or similar settings = Onelogin::Saml::Settings.new # This is the URL that the SP will tell the IdP send the response to settings.assertion_consumer_service_url = "http://sp.example.com/saml/consume" # Add the remote URL of the IdP metadata. You can also enter a local file path settings.idp_metadata = "http://idp.example.com/idp/Metadata" # This is your Entity ID (Note that Settings.entity_id is an alias to issuer) settings.issuer = "http://sp.example.com" #### The rest of these settings are *optional* #### # Defaults to urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST -- if you want to accept GET # requests to your ACS URL, set to "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" # Read the SAML specs for more goodies, like SOAP. settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" # The IdP metadata is cached using Rails.cache, and this selects how long # we assume the metadata is fresh. # Set this to a value in seconds: 300, 1.day.seconds, 2.weeks.seconds # Default is 1 day. settings.idp_metadata_ttl = 1.day.seconds # Defaults to urn:oasis:names:tc:SAML:2.0:nameid-format:transient -- look this up in the IdP # metadata to see what it supports. Most IdP's use a transient ID, but others use # persistant, email address, or all sorts of stuff. settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" # If you need to require a specific authentication context. Most people don't. #settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" end
What’s left at this point, is to wrap it all up in a controller and point the initialization and consumption URLs in OneLogin at that. A full controller example could look like this:
# This controller expects you to use the URLs /saml/initialize and /saml/consume in your OneLogin application. class SamlController < ApplicationController def initialize settings = Account.get_saml_settings request = Onelogin::Saml::Authrequest.new(settings) # Create the request, returning an action type and associated content action, content = request.create case action when "GET" # for GET requests, do a redirect on the content redirect_to content when "POST" # for POST requests (form) render the content as HTML render :inline => content end end def consume response = Onelogin::Saml::Response.new(params[:SAMLResponse]) response.settings = saml_settings if response.is_valid? && user = current_account.users.find_by_email(response.name_id) authorize_success(user) else authorize_failure(user) end end private def saml_settings settings = Onelogin::Saml::Settings.new settings.assertion_consumer_service_url = "http://#{request.host}/saml/consume" settings.issuer = request.host settings.idp_sso_target_url = "https://app.onelogin.com/saml/signon/#{OneLoginAppId}" settings.idp_cert_fingerprint = OneLoginAppCertFingerPrint settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" # Optional for most SAML IdPs settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" settings end end
If are using saml:AttributeStatement to transfer metadata, like the user name, you can access all the attributes through response.attributes. It contains all the saml:AttributeStatement with its ‘Name’ as a indifferent key and the one saml:AttributeValue as value.
response = Onelogin::Saml::Response.new(params[:SAMLResponse]) response.settings = saml_settings response.attributes[:username]
Logging out can actually be more complicated than logging in. Since each SP can create its own cookies, the IdP needs to keep track of which SPs have logged in. In order to log out, the IdP has to fire a request to log off each one. (That is, if the user wants to log out in all places)
To start the setup, define the URL and binding method in the Onelogin::Saml::Settings object.
settings.single_logout_service_url = "http://#{request.host}/saml/logout" # This is optional, POST is the default settings.single_logout_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
After adding this, the Metadata XML will reflect this change:
<md:EntityDescriptor xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata' entityID='sp.example.com'> <md:SPSSODescriptor protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'> ... <md:SingleLogoutService Location="http://sp.example.com/saml/logout" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" /> </md:SPSSODescriptor> </md:EntityDescriptor>
Then add a simple method to the SamlController to accept requests and responses to /saml/logout.
If you plan on implementing only SP SLO or IdP SLO you can remove those conditions.
(It’s a good thing to support both methods though)
class SamlController < ApplicationController # ... # Trigger SP and IdP initiated Logout requests def logout # If we're given a logout request, handle it in the IdP initiated method if params[:SAMLRequest] return idp_logout_request end # We've been given a response back from the IdP if params[:SAMLResponse] return logout_response end # No parameters means the browser hit this method directly. # Start the SP initiated SLO sp_logout_request end end
There are two methods to accomplish SLO – SP initiated and IdP initiated.
The SP (you) initiates the logout flow by sending a LogoutRequest to the IdP. The IdP deletes his session and cookies, then sends a LogoutResponse to the SP. If that response contains a success status, the SP can delete his session and cookies.
The difficulty in SP initiated SLO is that each SP needs to add a little more code to the logout functionality. Depending on how the IdP is setup, the user is usually given a page on the IdP asking which SPs to log out from. They then have to select each system to log out from. This can be confusing for the average web user.
# Create an SP initiated SLO def sp_logout_request # LogoutRequest accepts plain browser requests w/o paramters logout_request = Onelogin::Saml::LogoutRequest.new( :settings => @settings ) # Since we created a new SAML request, save the transaction_id # in the session to compare it with the response we get back. # You'll need a shared session storage in a clustered environment. session[:transaction_id] = logout_request.transaction_id # Create a new LogoutRequest for this session Name ID action, content = logout_request.create( :name_id => session[:userid] ) case action when "GET" # for GET requests, do a redirect on the content redirect_to content when "POST" # for POST requests (form) render the content as HTML render :inline => content end end # After sending an SP initiated LogoutRequest to the IdP, we need to accept # the LogoutResponse, verify it, then actually delete our session. def logout_response logout_response = Onelogin::Saml::LogoutResponse.new( :response => params[:SAMLResponse] ) # If the IdP gave us a signed response, verify it unless logout_response.is_valid? logger.error "The SAML Response signature validation failed" # For each error, add in some custom failure for your app end if session[:transation_id] && logout_response.in_response_to != session[:transaction_id] logger.error "The SAML Response for #{logout_response.in_response_to} does not match our session transaction ID of #{session[:transaction_id]}" # For each error, add in some custom failure for your app end # Optional sanity check if logout_response.issuer != @settings.idp_metadata logger.error "The SAML Response from IdP #{logout_response.issuer} does not match our trust relationship with #{@settings.idp_metadata}" # For each error, add in some custom failure for your app end # Actually log out this session if logout_response.success? logger.info "Delete session for '#{session[:nameid]}'" # Delete cookies, or whatever you need here session[:userid] = nil end end
In this method, the browser simply hits a URL on the IdP that looks something like: idp.example.com/Logout. This URL initiates the SLO by sending a LogoutRequest to all known SP sessions simultaneously with AJAX calls.
It waits for a successful LogoutResponse from each SP, then redirects the user back to some landing page after they are logged out from all relationships.
This method is easier to implement. All of the SP relationships use a single URL for a logout link, and there is less setup involved. The end users are probably happier since one click does the log out.
# Method to handle IdP initiated logouts def idp_logout_request logout_request = Onelogin::Saml::LogoutRequest.new( :request => params[:SAMLRequest], :settings => @settings) unless logout_request.is_valid? logger.error "IdP initiated LogoutRequest was not valid!" # For each error, add in some custom failure for your app end # Check that the name ID's match if session[:nameid] != logout_request.name_id logger.error "The session's Name ID '#{session[:nameid]}' does not match the LogoutRequest's Name ID '#{logout_request.name_id}'" # For each error, add in some custom failure for your app end # Actually log out this session # Delete cookies, or whatever you need here session[:userid] = nil # Generate a response to the IdP. :transaction_id sets the InResponseTo # SAML message to create a reply to the IdP in the LogoutResponse. action, content = logout_response = Onelogin::Saml::LogoutResponse.new( :settings => @settings ).create( :transaction_id => logout_request.transaction_id ) case action when "GET" # for GET requests, do a redirect on the content redirect_to content when "POST" # for POST requests (form) render the content as HTML render :inline => content end end
Please check github.com/onelogin/ruby-saml-example for a very basic sample Rails application using this gem.
ruby-saml-rails3-example
Please checkout github.com/calh/ruby-saml-rails3-example for a working Rails 3 demo on using ruby-saml.
-
Fork the project.
-
Make your feature addition or bug fix.
-
Add tests for it. This is important so I don’t break it in a future version unintentionally.
-
Commit, do not mess with rakefile, version, or history. (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
-
Send me a pull request. Bonus points for topic branches.