SAML and Ruby: Building a Service Provider
This post is part of the broader series around SAML and Ruby.
If you’re building a Rails site that needs to act as a SAML service provider, you’ve got two key options: you can use a third party service to manage the integration with identity providers, or you can build out the logic yourself.
There are a great many third party services to consider, including Auth0, Shibboleth, Firebase, Kinde, KeyCloak, and FusionAuth. Some of these are closed-source paid services, others are open source - often with a paid option for managed hosting. There’s value in these options, so you may want to investigate further.
However, building support directly in your Ruby or Rails app isn’t actually as daunting as I first feared - and as a bonus, it lets you retain control of the customer/user data, rather than being beholden to the limitations and terms of a separate service.
ruby-saml and Osso
The two key things that greatly helped us with building out service provider support into our app at Covidence are:
- The ruby-saml gem, which has existed for many years, and so has been extensively tested against a wide variety of identity providers;
- And a blog post by Osso on how to use that gem in a Rails application.
Osso was once a third party option, and while they don’t exist as a business any more, I’m very glad for their generous spirit in sharing a solid starting point for Ruby developers diving into SAML. You’ll be well-served by reading their post, but in case a shorter summary is useful, here’s our take.
The way out
There are two key endpoints required to behave as a SAML service provider: one that redirects your visitors out to the identity provider, and one that accepts the resulting response when they’re sent back to your site with a verified identity.
For me, these work well as new and create actions in a single controller. Let's take them one at a time.
class SAMLController < ApplicationController
def new
# Generate a new SAML request
saml_request = OneLogin::RubySaml::Authrequest.new
# Send the current visitor away to the IdP:
redirect_to(
saml_request.create(
# These are settings for the specific IdP:
saml_settings,
# This is your own context/state, which the IdP does not
# care about but it will send it back to you:
RelayState: "new-user-request"
),
# Ensure Rails is okay with you redirecting people away to
# a different site:
allow_other_host: true
)
end
end
In this action, we’re generating a new SAML request, and then using it to build a redirect URL for a specific identity provider (the saml_settings
method) and our own app’s context or state (the RelayState
parameter). Relay states will be sent back to us by the IdP - the value of it is entirely up to you, but should be a maximum of 80 bytes. The IdP will not parse it, so it’s purely for your own app’s use.
The saml_settings
method could look something like the following:
def saml_settings
settings = OneLogin::RubySaml::Settings.new
# Where the IdP sends users back to on our site:
settings.assertion_consumer_service_url =
"http://#{request.host}/saml_sessions"
# A unique identifier of our service, sometimes requested by
# the IdP:
settings.sp_entity_id =
"http://#{request.host}/saml/metadata"
# A unique identifier of the IdP:
settings.idp_entity_id =
"https://google.com/..."
# The IdP URL our `new` action redirects users to:
settings.idp_sso_service_url =
"https://google.com/saml/..."
# The X.509 certificate used to sign requests for the IdP:
settings.idp_cert = <<~CERT
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
CERT
settings
end
This method is generating an object that contains all the relevant details for interacting with a given identity provider. The examples in the code are referring to Google, but you’ll want to update it for the IdP you’re actually talking to.
assertion_consumer_service_url
is a URL is where visitors will be redirected back to on a successful authentication. This should point to thecreate
action in this controller we’re working on.sp_entity_id
is an identifier for your service, and should be unique from the perspective of the identity provider.idp_entity_id
is an identifier for the identity provider, supplied by them, and should also be considered unique.idp_sso_service_url
is supplied by the identity provider, and is a live URL where we redirect visitors to.idp_cert
is a X509 certificate supplied by the identity provider, used for signing requests.
The entity IDs for both service providers and identity providers are usually URLs. This is not a hard requirement for the SAML specification, but seems to have become a de-facto standard.
These URLs don’t need to be functional - it doesn’t matter if they return a 404 - but it is recommended that they return SAML metadata outlining the provider’s details as an XML document (though this is beyond the scope of this post).
There are other settings that your IdP may require - these can be specified as per the ruby-saml documentation, or parsed via their XML metadata document:
parser = OneLogin::RubySaml::IdpMetadataParser.new
settings = parser.parse_remote("https://example.com/idp/metadata")
It is very strongly recommended that these settings are cached regularly, rather than requested for every new SAML request, so your site isn’t beholden to Internet connectivity glitches or failures on the IdP site.
The way back in
The above new
action sends visitors off to the IdP - but then you’ll want an endpoint for their return. It could look something like the following:
class SAMLController < Application Controller
# Disable CSRF checks for our create action:
skip_before_action(
:verify_authenticity_token, only: [:create]
)
def new
# ... as above
end
def create
# Parse the given SAML response
saml_response = OneLogin::RubySaml::Response.new(
params[:SAMLResponse]
)
# And apply the same IdP configuration settings
saml_response.settings = saml_settings
# If it's a valid response, then we have a confirmed identity
# and can log the visitor in:
if saml_response.is_valid?
session[:userid] = saml_response.nameid
else
# Otherwise, the response is invalid - you'll probably want
# to provide some feedback and ask people to try logging in
# again.
# ...
end
end
private
def saml_settings
# ... as above
end
end
When the HTTP request comes in, we want to verify the SAML response with the same IdP settings as before. If the SAML response is valid, then we know we have a confirmed identity, and can use that to log them into our site.
The supplied nameid
(or name_id
) from the IdP might be an email address, or a persistent unique identifier for the user/identity, or even a more ephemeral reference. It varies for each identity provider, and sometimes can be configured - so it’s best to ensure you know ahead of time what you’re dealing with here. If you need to handle a variety of name IDs, then looking at saml_response.name_id_format
could be helpful.
As for the logic of actually logging someone into your site with this identity - well, that’s going to depend on how you’ve implemented authentication, whether it’s via Devise or Clearance or another gem, or something you’re rolling yourself. At this point, the SAML flow is complete, so the rest is up to you.
But perhaps you want to read some of the other posts, to get a sense of how to test all of this!