saml-kit is a powerful Ruby gem for generating standards-compliant SAML 2.0 documents. This guide covers implementing SAML Service Provider metadata with real production examples.
Basic Service Provider Metadata
To generate metadata specifically for a service provider you can write:
metadata = Saml::Kit::Metadata.build do |x|
x.entity_id = "https://www.example.org/metadata"
x.organization_name = "Acme"
x.contact_email = "acme@example.org"
x.organization_url = "https://www.example.org"
x.build_service_provider do |_|
_.add_assertion_consumer_service('https://www.example.org/assertions', binding: :http_post)
end
end
puts metadata.to_xml(pretty: true)
The above code will generate an xml document that conforms to the SAML 2.0 specification. In this example the metadata is not signed, and 0 signing and/or encryption certificates are configured.
<?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_0c83f3eb-6b21-47a6-9da0-cf6dc4ee941f" entityID="https://www.example.org/metadata">
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://www.example.org/assertions" index="0" isDefault="true"/>
</SPSSODescriptor>
<Organization>
<OrganizationName xml:lang="en">Acme</OrganizationName>
<OrganizationDisplayName xml:lang="en">Acme</OrganizationDisplayName>
<OrganizationURL xml:lang="en">https://www.example.org</OrganizationURL>
</Organization>
<ContactPerson contactType="technical">
<Company>mailto:acme@example.org</Company>
</ContactPerson>
</EntityDescriptor>
Internally, saml-kit has a global configuration that is used for generating messages. You can override the global configuration with most methods and classes in saml-kit but generally having a single global configuration is enough for most applications. To register an x509 certificate with a private key for generating signatures, you can configure saml-kit like this:
Saml::Kit.configure do |config|
config.entity_id = "https://www.example.org/metadata"
config.add_key_pair(
ENV['X509'],
ENV['PRIVATE_KEY'],
use: :signing
)
end
metadata = Saml::Kit::Metadata.build do |x|
x.organization_name = "Acme"
x.contact_email = "acme@example.org"
x.organization_url = "https://www.example.org"
x.build_service_provider do |_|
_.add_assertion_consumer_service(
'https://www.example.org/assertions',
binding: :http_post
)
end
end
puts metadata.to_xml(pretty: true)
This will now generate a service provider metadata that is signed and specifies the x509 certificate used for generating signatures.
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_70a91ccf-1948-4df1-9f87-ebfd1be8020f" entityID="https://www.example.org/metadata">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#_70a91ccf-1948-4df1-9f87-ebfd1be8020f">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<DigestValue>...</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>...</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>...</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
<SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>...</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://www.example.org/assertions" index="0" isDefault="true"/>
</SPSSODescriptor>
<Organization>
<OrganizationName xml:lang="en">Acme</OrganizationName>
<OrganizationDisplayName xml:lang="en">Acme</OrganizationDisplayName>
<OrganizationURL xml:lang="en">https://www.example.org</OrganizationURL>
</Organization>
<ContactPerson contactType="technical">
<Company>mailto:acme@example.org</Company>
</ContactPerson>
</EntityDescriptor>
saml-kit also supports generating and consuming encrypted messages.
If your service provider would like to receive encrypted assertions you
can include an encryption certificate. This example will also highlight
a few different ways that configuration can be generated and provided.
configuration = Saml::Kit::Configuration.new do |config|
config.entity_id = "https://www.example.org/metadata"
config.generate_key_pair_for(use: :signing)
config.generate_key_pair_for(use: :encryption)
end
metadata = Saml::Kit::Metadata.build(configuration: configuration) do |x|
x.organization_name = "Acme"
x.contact_email = "acme@example.org"
x.organization_url = "https://www.example.org"
x.build_service_provider do |_|
_.add_assertion_consumer_service('https://www.example.org/assertions', binding: :http_post)
end
end
puts metadata.to_xml(pretty: true)
This will produce:
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_23bec3a7-17f3-4009-9b8b-d1927115ad28" entityID="https://www.example.org/metadata">
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="#_23bec3a7-17f3-4009-9b8b-d1927115ad28">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<DigestValue>...</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>...</SignatureValue>
<KeyInfo>
<X509Data>
<X509Certificate>...</X509Certificate>
</X509Data>
</KeyInfo>
</Signature>
<SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="encryption">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>...</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>...</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://www.example.org/assertions" index="0" isDefault="true"/>
</SPSSODescriptor>
<Organization>
<OrganizationName xml:lang="en">Acme</OrganizationName>
<OrganizationDisplayName xml:lang="en">Acme</OrganizationDisplayName>
<OrganizationURL xml:lang="en">https://www.example.org</OrganizationURL>
</Organization>
<ContactPerson contactType="technical">
<Company>mailto:acme@example.org</Company>
</ContactPerson>
</EntityDescriptor>
With this in place identity providers can now send you encrypted assertions using your encryption certificate. Identity providers will also be able to verify that messages originated from your service provider, by verifying any signatures embedded in messages sent from your service provider to the identity provider.
Production Service Provider Implementation
Here’s how I implement SAML Service Provider metadata in a production Rails application (source):
Service Provider Metadata Controller
# app/controllers/sp_metadata_controller.rb
class SpMetadataController < ApplicationController
def show
metadata = Saml::Kit::Metadata.build do |builder|
builder.entity_id = sp_entity_id
builder.organization_name = Rails.application.class.module_parent_name
builder.contact_email = ENV.fetch('SUPPORT_EMAIL', 'support@example.com')
builder.organization_url = root_url
builder.build_service_provider do |sp|
# Assertion Consumer Service - where SAML responses are posted
sp.add_assertion_consumer_service(
saml_acs_url,
binding: :http_post
)
# Single Logout Service - for SAML logout flows
sp.add_single_logout_service(
saml_sls_url,
binding: :http_post
)
# Single Logout Service for HTTP-Redirect binding
sp.add_single_logout_service(
saml_sls_url,
binding: :http_redirect
)
end
end
respond_to do |format|
format.xml { render xml: metadata.to_xml }
format.html { render xml: metadata.to_xml }
end
end
private
def sp_entity_id
"#{request.protocol}#{request.host_with_port}/sp/metadata"
end
def saml_acs_url
"#{request.protocol}#{request.host_with_port}/saml/acs"
end
def saml_sls_url
"#{request.protocol}#{request.host_with_port}/saml/sls"
end
end
SAML Configuration for Service Provider
# config/initializers/saml_kit.rb
Saml::Kit.configure do |config|
config.entity_id = ENV.fetch('SAML_SP_ENTITY_ID') { "#{Rails.application.routes.url_helpers.root_url}sp/metadata" }
# Configure signing certificate for metadata and authentication requests
if Rails.env.production?
config.add_key_pair(
ENV.fetch('SAML_SP_CERTIFICATE'),
ENV.fetch('SAML_SP_PRIVATE_KEY'),
use: :signing
)
# Optional: separate encryption certificate
config.add_key_pair(
ENV.fetch('SAML_SP_ENCRYPTION_CERTIFICATE'),
ENV.fetch('SAML_SP_ENCRYPTION_PRIVATE_KEY'),
use: :encryption
)
else
# Generate temporary keys for development
config.generate_key_pair_for(use: :signing)
config.generate_key_pair_for(use: :encryption)
end
# Configure assertion requirements
config.assertion_consumer_service_binding = :http_post
config.single_logout_service_binding = :http_post
# Security settings
config.default_name_id_format = Saml::Kit::Namespaces::PERSISTENT
end
Service Provider Routes
# config/routes.rb
Rails.application.routes.draw do
# Service Provider metadata endpoint
get '/sp/metadata', to: 'sp_metadata#show', as: :sp_metadata
# SAML endpoints
post '/saml/acs', to: 'saml#acs', as: :saml_acs # Assertion Consumer Service
match '/saml/sls', to: 'saml#sls', via: [:post, :get], as: :saml_sls # Single Logout Service
get '/saml/login', to: 'saml#login', as: :saml_login # Initiate SAML login
end
Service Provider Authentication Flow
# app/controllers/saml_controller.rb
class SamlController < ApplicationController
skip_before_action :authenticate_user!, only: [:login, :acs, :sls]
# Initiate SAML authentication (SP-initiated flow)
def login
idp_metadata_url = params[:idp] || ENV.fetch('SAML_IDP_METADATA_URL')
# Build authentication request
auth_request = Saml::Kit::AuthenticationRequest.build do |builder|
builder.destination = idp_metadata_url
builder.assertion_consumer_service_url = saml_acs_url
end
# Store request ID for validation
session[:saml_request_id] = auth_request.id
# Redirect to Identity Provider
redirect_to auth_request.redirect_url, allow_other_host: true
end
# Handle SAML response (Assertion Consumer Service)
def acs
saml_response = Saml::Kit::Response.deserialize(params[:SAMLResponse])
# Validate response
unless saml_response.valid?
Rails.logger.error "Invalid SAML response: #{saml_response.errors.full_messages}"
redirect_to new_user_session_path, alert: 'SAML authentication failed'
return
end
# Validate in-response-to matches stored request ID
unless saml_response.in_response_to == session[:saml_request_id]
Rails.logger.error "SAML response ID mismatch"
redirect_to new_user_session_path, alert: 'SAML authentication failed'
return
end
# Extract user information from SAML assertion
assertion = saml_response.assertion
email = assertion.attribute_value_for('email') || assertion.name_id
name = assertion.attribute_value_for('name')
# Find or create user
user = User.find_or_initialize_by(email: email)
user.name = name if name.present?
user.save!
# Sign in user
sign_in(user)
session.delete(:saml_request_id)
redirect_to root_path, notice: 'Successfully authenticated via SAML'
end
# Handle SAML logout (Single Logout Service)
def sls
if request.post?
# Handle logout response from IdP
logout_response = Saml::Kit::LogoutResponse.deserialize(params[:SAMLResponse])
if logout_response.valid?
sign_out(current_user) if user_signed_in?
redirect_to root_path, notice: 'Successfully logged out'
else
redirect_to root_path, alert: 'Logout failed'
end
else
# Handle logout request from IdP
logout_request = Saml::Kit::LogoutRequest.deserialize(params[:SAMLRequest])
if logout_request.valid?
sign_out(current_user) if user_signed_in?
# Build logout response
logout_response = Saml::Kit::LogoutResponse.build(logout_request) do |builder|
builder.status_code = Saml::Kit::Namespaces::SUCCESS
end
redirect_to logout_response.redirect_url, allow_other_host: true
else
redirect_to root_path, alert: 'Invalid logout request'
end
end
end
private
def saml_acs_url
"#{request.protocol}#{request.host_with_port}/saml/acs"
end
end
Generated Metadata Output
With the above configuration, your Service Provider will generate metadata similar to this:
<?xml version="1.0"?>
<EntityDescriptor
xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
entityID="https://your-app.com/sp/metadata">
<SPSSODescriptor
AuthnRequestsSigned="true"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<!-- Signing certificate -->
<KeyDescriptor use="signing">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>MIIDXzCCAkegAwIBAgIJAK...</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<!-- Encryption certificate -->
<KeyDescriptor use="encryption">
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>MIIDXzCCAkegAwIBAgIJAL...</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<!-- Single Logout Services -->
<SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://your-app.com/saml/sls"/>
<SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://your-app.com/saml/sls"/>
<!-- Supported NameID formats -->
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
<!-- Assertion Consumer Service -->
<AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://your-app.com/saml/acs"
index="0"
isDefault="true"/>
</SPSSODescriptor>
<!-- Organization information -->
<Organization>
<OrganizationName xml:lang="en">Your Application</OrganizationName>
<OrganizationDisplayName xml:lang="en">Your Application</OrganizationDisplayName>
<OrganizationURL xml:lang="en">https://your-app.com</OrganizationURL>
</Organization>
<!-- Technical contact -->
<ContactPerson contactType="technical">
<Company>mailto:support@your-app.com</Company>
</ContactPerson>
</EntityDescriptor>
Security Best Practices
Certificate Management
# Certificate rotation strategy
class CertificateRotator
def self.rotate_certificates!
# Generate new certificates
new_signing_cert = generate_certificate(use: :signing)
new_encryption_cert = generate_certificate(use: :encryption)
# Update configuration with both old and new certificates
Saml::Kit.configure do |config|
# Keep old certificates for compatibility
config.add_key_pair(old_signing_cert, old_signing_key, use: :signing)
config.add_key_pair(old_encryption_cert, old_encryption_key, use: :encryption)
# Add new certificates
config.add_key_pair(new_signing_cert, new_signing_key, use: :signing)
config.add_key_pair(new_encryption_cert, new_encryption_key, use: :encryption)
end
# Schedule removal of old certificates after IdPs have updated
DeprecateCertificatesJob.set(wait: 30.days).perform_later
end
end
Metadata Validation
# Validate metadata before serving
class MetadataValidator
def self.validate!(metadata)
doc = Nokogiri::XML(metadata.to_xml)
# Ensure required elements are present
raise 'Missing EntityDescriptor' unless doc.at_xpath('//md:EntityDescriptor', 'md' => 'urn:oasis:names:tc:SAML:2.0:metadata')
raise 'Missing SPSSODescriptor' unless doc.at_xpath('//md:SPSSODescriptor', 'md' => 'urn:oasis:names:tc:SAML:2.0:metadata')
raise 'Missing AssertionConsumerService' unless doc.at_xpath('//md:AssertionConsumerService', 'md' => 'urn:oasis:names:tc:SAML:2.0:metadata')
# Validate certificate presence in production
if Rails.env.production?
raise 'Missing signing certificate' unless doc.at_xpath('//md:KeyDescriptor[@use="signing"]', 'md' => 'urn:oasis:names:tc:SAML:2.0:metadata')
end
true
end
end
Complete Implementation
The above examples are from a working SAML Service Provider implementation. You can explore the complete codebase:
GitHub Repository: https://github.com/xlgmokha/proof
Key Service Provider files:
- SP Metadata Controller - Service Provider metadata generation
- SAML Controller - SAML authentication flows
- SAML Configuration - saml-kit setup
- User Model - User management integration
Run the complete SAML Service Provider:
git clone https://github.com/xlgmokha/proof.git
cd proof
bundle install
rails db:setup
rails server
# Service Provider endpoints available at:
# GET /sp/metadata - Service Provider metadata
# GET /saml/login - Initiate SAML authentication
# POST /saml/acs - Assertion Consumer Service
# POST/GET /saml/sls - Single Logout Service
Test Service Provider integration:
# Get SP metadata
curl -X GET "http://localhost:3000/sp/metadata"
# Initiate SAML login (redirects to configured IdP)
curl -X GET "http://localhost:3000/saml/login"
# Test with SAML response (requires valid SAML response from IdP)
curl -X POST "http://localhost:3000/saml/acs" \
-d "SAMLResponse=base64_encoded_saml_response"
This provides a production-ready SAML Service Provider implementation with proper certificate management, security validation, and complete authentication flows.
Give saml-kit a try and checkout the documentation for more examples.