~/src/www.mokhan.ca/xlgmokha [main]
cat saml-service-provider-metadata.md
saml-service-provider-metadata.md 59524 bytes | 2018-11-15 00:00
symlink: /opt/ruby/saml-service-provider-metadata.md

SAML Service Provider Metadata - Complete Implementation Guide

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:

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.