├── .gitignore ├── lib ├── rack-saml.rb ├── rack-saml │ └── version.rb └── rack │ ├── saml │ ├── metadata │ │ ├── opensaml_metadata.rb │ │ ├── abstract_metadata.rb │ │ └── onelogin_metadata.rb │ ├── request │ │ ├── opensaml_request.rb │ │ ├── abstract_request.rb │ │ └── onelogin_request.rb │ ├── response │ │ ├── opensaml_response.rb │ │ ├── abstract_response.rb │ │ └── onelogin_response.rb │ ├── request_handler.rb │ ├── metadata_handler.rb │ ├── misc │ │ └── onelogin_setting.rb │ └── response_handler.rb │ └── saml.rb ├── .travis.yml ├── Gemfile ├── Rakefile ├── config ├── attribute-map.yml ├── rack-saml.yml ├── attribute-map.yml.sample └── metadata.yml ├── rack-saml.gemspec ├── bin └── conv_metadata.rb └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | **~ 3 | pkg/* 4 | -------------------------------------------------------------------------------- /lib/rack-saml.rb: -------------------------------------------------------------------------------- 1 | require 'rack/saml' 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - gem update --system 3 | - gem update bundler 4 | -------------------------------------------------------------------------------- /lib/rack-saml/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Saml 3 | VERSION = "0.2.4" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gemspec 4 | 5 | #group :example do 6 | # gem 'sinatra' 7 | #end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList['test/**/test_*.rb'] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /lib/rack/saml/metadata/opensaml_metadata.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class OpensamlMetadata < AbstractMetadata 4 | # to be implemented 5 | def initialize(request, config, metadata) 6 | end 7 | 8 | def generate 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rack/saml/request/opensaml_request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class OpensamlRequest < AbstractRequest 4 | # To be implemented 5 | def initialize(request, config, metadata) 6 | end 7 | 8 | def redirect_uri 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rack/saml/response/opensaml_response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class OpensamlResponse < AbstractResponse 4 | # To be implemented 5 | def initialize(request, config, metadata) 6 | end 7 | 8 | def redirect_uri 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/rack/saml/metadata/abstract_metadata.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class AbstractMetadata 4 | attr_reader :request, :config, :metadata 5 | 6 | def initialize(request, config, metadata) 7 | @request = request 8 | @config = config 9 | @metadata = metadata 10 | end 11 | 12 | def generate 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rack/saml/request/abstract_request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class AbstractRequest 4 | attr_reader :request, :config, :metadata 5 | 6 | def initialize(request, config, metadata) 7 | @request = request 8 | @config = config 9 | @metadata = metadata 10 | end 11 | 12 | def redirect_uri 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rack/saml/response/abstract_response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | class AbstractResponse 4 | attr_reader :request, :config, :metadata 5 | 6 | def initialize(request, config, metadata) 7 | @request = request 8 | @config = config 9 | @metadata = metadata 10 | end 11 | 12 | def is_valid? 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/attribute-map.yml: -------------------------------------------------------------------------------- 1 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "eppn" 2 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.9": "affiliation" 3 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.1": "unscoped-affiliation" 4 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.7": "entitlement" 5 | "urn:oid:0.9.2342.19200300.100.1.1": "uid" 6 | "urn:oid:0.9.2342.19200300.100.1.3": "mail" 7 | "urn:oid:2.16.840.1.113730.3.1.241": "displayName" 8 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.10": "persistent-id" 9 | -------------------------------------------------------------------------------- /config/rack-saml.yml: -------------------------------------------------------------------------------- 1 | protected_path: /auth/shibboleth/callback 2 | metadata_path: /Shibboleth.sso/Metadata 3 | assertion_handler: onelogin 4 | saml_idp: https://localhost/idp/shibboleth 5 | saml_sess_timeout: 1800 6 | shib_app_id: default 7 | shibb_ds: https://localhost/discovery/WAYF 8 | allowed_clock_drift: 60 9 | validation_error: true 10 | sp_cert: config/cert/development_cert.pem 11 | sp_key: config/cert/development_key.pem 12 | want_assertions_encrypted: false 13 | -------------------------------------------------------------------------------- /lib/rack/saml/metadata/onelogin_metadata.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/misc/onelogin_setting' 4 | 5 | class OneloginMetadata < AbstractMetadata 6 | include OneloginSetting 7 | 8 | def initialize(request, config, metadata) 9 | super(request, config, metadata) 10 | @sp_metadata = OneLogin::RubySaml::Metadata.new 11 | end 12 | 13 | def generate 14 | @sp_metadata.generate(saml_settings) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rack/saml/request/onelogin_request.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/misc/onelogin_setting' 4 | 5 | class OneloginRequest < AbstractRequest 6 | include OneloginSetting 7 | 8 | def initialize(request, config, metadata) 9 | super(request, config, metadata) 10 | @authrequest = OneLogin::RubySaml::Authrequest.new 11 | end 12 | 13 | def redirect_uri 14 | @authrequest.create(saml_settings) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/rack/saml/request_handler.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/request/abstract_request' 4 | autoload "OneloginRequest", 'rack/saml/request/onelogin_request' 5 | autoload "OpensamlRequest", 'rack/saml/request/opensaml_request' 6 | 7 | class RequestHandler 8 | attr_reader :authn_request 9 | 10 | # Rack::Saml::RequestHandler 11 | # request: Rack current request instance 12 | # config: config/saml.yml 13 | # metadata: specified idp entity in the config/metadata.yml 14 | def initialize(request, config, metadata) 15 | @authn_request = (eval "Rack::Saml::#{config['assertion_handler'].to_s.capitalize}Request").new(request, config, metadata) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/rack/saml/metadata_handler.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/metadata/abstract_metadata' 4 | autoload "OneloginMetadata", 'rack/saml/metadata/onelogin_metadata' 5 | autoload "OpensamlMetadata", 'rack/saml/metadata/opensaml_metadata' 6 | 7 | class MetadataHandler 8 | attr_reader :sp_metadata 9 | 10 | # Rack::Saml::MetadataHandler 11 | # request: Rack current request instance 12 | # config: config/rack-saml.yml 13 | # metadata: specified idp entity in the config/metadata.yml 14 | def initialize(request, config, metadata) 15 | @sp_metadata = (eval "Rack::Saml::#{config['assertion_handler'].to_s.capitalize}Metadata").new(request, config, metadata) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/attribute-map.yml.sample: -------------------------------------------------------------------------------- 1 | "urn:mace:dir:attribute-def:eduPersonPrincipalName": "eppn" 2 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.6": "eppn" 3 | "urn:mace:dir:attribute-def:eduPersonScopedAffiliation": "affiliation" 4 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.9": "affiliation" 5 | "urn:mace:dir:attribute-def:eduPersonAffiliation": "unscoped-affiliation" 6 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.1": "unscoped-affiliation" 7 | "urn:mace:dir:attribute-def:eduPersonEntitlement": "entitlement" 8 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.7": "entitlement" 9 | "urn:mace:dir:attribute-def:uid": "uid" 10 | "urn:oid:0.9.2342.19200300.100.1.1": "uid" 11 | "urn:mace:dir:attribute-def:mail": "mail" 12 | "urn:oid:0.9.2342.19200300.100.1.3": "mail" 13 | "urn:mace:dir:attribute-def:displayName": "displayName" 14 | "urn:oid:2.16.840.1.113730.3.1.241": "displayName" 15 | "urn:oid:1.3.6.1.4.1.5923.1.1.1.10": "persistent-id" 16 | -------------------------------------------------------------------------------- /rack-saml.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/rack-saml/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.add_dependency 'rack' 6 | gem.add_dependency 'ruby-saml', '~> 1.7.0' 7 | gem.add_development_dependency 'rack-test' 8 | gem.add_development_dependency 'rake' 9 | gem.add_development_dependency 'rspec' 10 | 11 | gem.license = 'MIT' 12 | 13 | gem.authors = ["Toyokazu Akiyama"] 14 | gem.email = ["toyokazu@gmail.com"] 15 | gem.description = %q{SAML middleware for Rack (using ruby-saml)} 16 | gem.summary = %q{SAML middleware for Rack (using ruby-saml)} 17 | gem.homepage = "" 18 | 19 | gem.files = `find . -not \\( -regex ".*\\.git.*" -o -regex "\\./pkg.*" -o -regex "\\./spec.*" \\)`.split("\n").map{ |f| f.gsub(/^.\//, '') } 20 | gem.test_files = `find spec/*`.split("\n") 21 | gem.name = "rack-saml" 22 | gem.require_paths = ["lib"] 23 | gem.version = Rack::Saml::VERSION 24 | end 25 | -------------------------------------------------------------------------------- /lib/rack/saml/response/onelogin_response.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/misc/onelogin_setting' 4 | 5 | class OneloginResponse < AbstractResponse 6 | include OneloginSetting 7 | #extend Forwardable 8 | 9 | def initialize(request, config, metadata) 10 | super(request, config, metadata) 11 | @response = OneLogin::RubySaml::Response.new(@request.params['SAMLResponse'], { 12 | :allowed_clock_drift => config['allowed_clock_drift'], 13 | :settings => saml_settings 14 | }) 15 | end 16 | 17 | def is_valid? 18 | begin 19 | if config['validation_error'] 20 | @response.validate! 21 | else 22 | @response.is_valid? 23 | end 24 | rescue OneLogin::RubySaml::ValidationError => e 25 | raise ValidationError.new(e.message) 26 | end 27 | end 28 | 29 | def attributes 30 | @response.attributes 31 | end 32 | 33 | #def_delegator :@response, :is_valid?, :attributes 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/rack/saml/misc/onelogin_setting.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | module OneloginSetting 4 | require 'ruby-saml' 5 | 6 | def saml_settings 7 | settings = OneLogin::RubySaml::Settings.new 8 | settings.assertion_consumer_service_url = @config['assertion_consumer_service_uri'] 9 | settings.issuer = @config['saml_sp'] 10 | if ENV['SP_CERT'] 11 | settings.certificate = ENV['SP_CERT'] 12 | elsif @config['sp_cert'] 13 | settings.certificate = ::File.open(@config['sp_cert'], 'r').read 14 | end 15 | if ENV['SP_KEY'] 16 | settings.private_key = ENV['SP_KEY'] 17 | elsif @config['sp_key'] 18 | settings.private_key = ::File.open(@config['sp_key'], 'r').read 19 | end 20 | settings.idp_sso_target_url = @metadata['saml2_http_redirect'] 21 | settings.idp_cert = @metadata['certificate'] 22 | settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" 23 | settings.security[:want_assertions_encrypted] = @config['want_assertions_encrypted'] 24 | #settings.authn_context = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" 25 | settings 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rack/saml/response_handler.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | class Saml 3 | require 'rack/saml/response/abstract_response' 4 | autoload "OneloginResponse", 'rack/saml/response/onelogin_response' 5 | autoload "OpensamlResponse", 'rack/saml/response/opensaml_response' 6 | 7 | class ResponseHandler 8 | attr_reader :response 9 | 10 | # Rack::Saml::ResponseHandler 11 | # request: Rack current request instance 12 | # config: config/saml.yml 13 | # metadata: specified idp entity in the config/metadata.yml 14 | def initialize(request, config, metadata) 15 | @response = (eval "Rack::Saml::#{config['assertion_handler'].to_s.capitalize}Response").new(request, config, metadata) 16 | end 17 | 18 | def extract_attrs(env, session, attribute_map) 19 | if session.env.empty? 20 | attribute_map.each do |attr_name, env_name| 21 | attribute = @response.attributes[attr_name] 22 | if !attribute.nil? 23 | session.env[env_name] = attribute 24 | end 25 | end 26 | if !@response.config['shib_app_id'].nil? 27 | session.env['Shib-Application-ID'] = @response.config['shib_app_id'] 28 | end 29 | session.env['Shib-Session-ID'] = session.get_sid('saml_res') 30 | end 31 | session.env.each do |k, v| 32 | env[k] = v 33 | end 34 | end 35 | 36 | def self.extract_attrs(env, session) 37 | session.env.each do |k, v| 38 | env[k] = v 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /config/metadata.yml: -------------------------------------------------------------------------------- 1 | --- 2 | idp_lists: 3 | https://localhost/idp/shibboleth: 4 | certificate: |- 5 | -----BEGIN CERTIFICATE----- 6 | MIIEKDCCAxCgAwIBAgIJALh5qhU6z0gMMA0GCSqGSIb3DQEBBQUAMIGKMQswCQYD 7 | VQQGEwJKUDERMA8GA1UEBwwIQWNhZGVtZTIxIDAeBgNVBAoMF0t5b3RvIFNhbmd5 8 | byBVbml2ZXJzaXR5MTQwMgYDVQQLDCtGYWN1bHR5IG9mIENvbXB1dGVyIFNjaWVu 9 | Y2UgYW5kIEVuZ2luZWVyaW5nMRAwDgYDVQQDDAdUZXN0IENBMB4XDTExMDgxMDA3 10 | MTY1OFoXDTEyMDgwOTA3MTY1OFowgZ4xCzAJBgNVBAYTAkpQMREwDwYDVQQHDAhB 11 | Y2FkZW1lMjEgMB4GA1UECgwXS3lvdG8gU2FuZ3lvIFVuaXZlcnNpdHkxNDAyBgNV 12 | BAsMK0ZhY3VsdHkgb2YgQ29tcHV0ZXIgU2NpZW5jZSBhbmQgRW5naW5lZXJpbmcx 13 | JDAiBgNVBAMMG2lkcC50ZXN0LmNzZS5reW90by1zdS5hYy5qcDCCASIwDQYJKoZI 14 | hvcNAQEBBQADggEPADCCAQoCggEBAN1/g5HoajtIFLrFGEt6z6vyWS7CNhz/nOw9 15 | 7Ei0R7TYg/iwz3CuJdowlz7VnVTi/Oi2kz+YxuLw3RDwl5r16AKRCePyc0F63xSv 16 | 5rc6kJkXBqGZlKtSn5OeSa+w18c4MOtu4zSZP7wHhS5bMABUL8UBX1P021bPd7Ad 17 | 6gMt9mCF+0giIyTWMP8PH8EoDTMPq+ko1QosxSxPSaERB2+OEmwBa4NnKyBmB9lU 18 | NrWN5tB+BJ8qD9C+5EU+miWFzY59GC1JPc7TmQ28frobmgc22pUc6d/0+uBELEbx 19 | v7G942PYDCdF1V0chRyGAP4/7IAH77B5t6wIqhLBffawb993mi8CAwEAAaN7MHkw 20 | CQYDVR0TBAIwADAsBglghkgBhvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2Vy 21 | dGlmaWNhdGUwHQYDVR0OBBYEFDpB4W//bAwVauJd/pvUOfZl72eqMB8GA1UdIwQY 22 | MBaAFINIlekZI9dBQNn6x8vCKxS3h6CSMA0GCSqGSIb3DQEBBQUAA4IBAQBZwuZp 23 | TyczweI8L68yzkq//5ORzkoJtW29aftSfWrXIO4/ckydyqYNHW1H62J4QtMxljHG 24 | ZK+GAALGKIAYQTD805Ha4tezY/bpXB1HTu+E2e2jL6AmYEP62WcFdCmnPS7DSQ78 25 | LDLDDmrPBRfNTVxgjEq1GRS1JfQJb8JrNipG+YqCinNVKuEx4wsc7bIRbY0YZrVp 26 | +sRk6BB3HrOY1p+F/83in45wyNxGfgZpmdAvk9yB8ubzBhDaaNSqeU33iOGsqSBH 27 | jD2RyhOELbPlOMEHn+q2vSZdlo4hqRpamhahSBsuiQFbcwpTkWJ3SJyjYYn845Xw 28 | Uq9SgXh2ssVUFkni 29 | -----END CERTIFICATE----- 30 | saml2_http_redirect: https://localhost/idp/profile/SAML2/Redirect/SSO 31 | sp_lists: {} 32 | 33 | -------------------------------------------------------------------------------- /bin/conv_metadata.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'rexml/document' 3 | require 'uri' 4 | require 'yaml' 5 | 6 | DS = 'http://www.w3.org/2000/09/xmldsig#' 7 | 8 | if ARGV.size < 1 9 | puts "outputs yaml format metadata file" 10 | puts "usage: conv_metadata.rb metadata_file" 11 | exit(1) 12 | end 13 | 14 | file = File.new(ARGV[0]) 15 | doc = REXML::Document.new(file) 16 | 17 | def get_list_type(elem) 18 | if elem.elements.any? {|el| el.has_name?("IDPSSODescriptor")} 19 | return "idp_lists" 20 | end 21 | "sp_lists" 22 | end 23 | 24 | def create_entity_hash(elem, list_type) 25 | case list_type 26 | when "idp_lists" 27 | idp_elem = elem.elements.find {|el| el.has_name?("IDPSSODescriptor")} 28 | # the first certificate is used 29 | cert_elem = REXML::XPath.first(idp_elem, './/ds:X509Certificate', 'ds' => DS) 30 | # reject an IdP without a certificate 31 | if cert_elem.nil? 32 | puts "specified metadata has an IdP without certificate!" 33 | exit 1 34 | end 35 | # Cert must be split to 64 char lines (else OpenSSL gives "nested asn1" error) 36 | certificate = "-----BEGIN CERTIFICATE-----\n#{cert_elem.text.gsub(/\s+/, "").scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----" 37 | saml2_http_redirect = nil 38 | idp_elem.elements.find_all {|el| el.has_name?("SingleSignOnService")}.each do |e| 39 | if e.attributes["Binding"] == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" 40 | saml2_http_redirect = e.attributes["Location"] 41 | end 42 | end 43 | return {"certificate" => certificate, 44 | "saml2_http_redirect" => saml2_http_redirect} 45 | when "sp_lists" 46 | sp_elem = elem.elements.find {|el| el.has_name?("SPSSODescriptor")} 47 | #puts sp_elem.attributes["entityID"] 48 | # the first certificate is used 49 | # permit a SP without a certificate 50 | cert_elem = REXML::XPath.first(sp_elem, './/ds:X509Certificate', 'ds' => DS) 51 | certificate = cert_elem.nil? ? "" : "-----BEGIN CERTIFICATE-----\n#{cert_elem.text.gsub(/\s+/, "").scan(/.{1,64}/).join("\n")}\n-----END CERTIFICATE-----" 52 | saml2_http_post = nil 53 | sp_elem.elements.find_all {|el| el.has_name?("AssertionConsumerService")}.each do |e| 54 | if e.attributes["Binding"] == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" 55 | saml2_http_post = e.attributes["Location"] 56 | end 57 | end 58 | return {"certificate" => certificate, 59 | "saml2_http_post" => saml2_http_post} 60 | end 61 | end 62 | 63 | def add_entities(entities, elem) 64 | list_type = get_list_type(elem) 65 | entity_id = elem.attributes["entityID"] 66 | entities[list_type][entity_id] = create_entity_hash(elem, list_type) 67 | end 68 | 69 | entities = {"idp_lists" => {}, "sp_lists" => {}} 70 | doc.elements.find_all {|el| el.has_name?("EntityDescriptor")}.each do |elem| 71 | add_entities(entities, elem) 72 | end 73 | 74 | doc.elements.find_all {|el| el.has_name?("EntitiesDescriptor")}.each do |elem1| 75 | elem1.elements.find_all {|el| el.has_name?("EntityDescriptor")}.each do |elem2| 76 | add_entities(entities, elem2) 77 | end 78 | end 79 | 80 | puts entities.to_yaml 81 | -------------------------------------------------------------------------------- /lib/rack/saml.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'yaml' 3 | require 'securerandom' 4 | 5 | module Rack 6 | # Rack::Saml 7 | # 8 | # As the Shibboleth SP, Rack::Saml::Base adopts :protected_path 9 | # as an :assertion_consumer_path. It is easy to configure and 10 | # support omniauth-shibboleth. 11 | # To establish single path behavior, it currently supports only 12 | # HTTP Redirect Binding from SP to Idp 13 | # HTTP POST Binding from IdP to SP 14 | # 15 | # rack-saml uses rack.session to store SAML and Discovery Service 16 | # status. 17 | # env['rack.session'] = { 18 | # 'rack_saml' => { 19 | # 'ds.session' => { 20 | # 'sid' => temporally_generated_hash, 21 | # 'expires' => xxxxx # timestamp (string) 22 | # } 23 | # 'saml_authreq.session' => { 24 | # 'sid' => temporally_generated_hash, 25 | # 'expires' => xxxxx # timestamp (string) 26 | # } 27 | # 'saml_res.session' => { 28 | # 'sid' => temporally_generated_hash, 29 | # 'expires' => xxxxx, # timestamp (string) 30 | # 'env' => {} 31 | # } 32 | # } 33 | # } 34 | class Saml 35 | autoload "RequestHandler", 'rack/saml/request_handler' 36 | autoload "MetadataHandler", 'rack/saml/metadata_handler' 37 | autoload "ResponseHandler", 'rack/saml/response_handler' 38 | 39 | class ValidationError < StandardError 40 | end 41 | 42 | FILE_TYPE = [:config, :metadata, :attribute_map] 43 | FILE_NAME = { 44 | :config => 'rack-saml.yml', 45 | :metadata => 'metadata.yml', 46 | :attribute_map => 'attribute-map.yml' 47 | } 48 | 49 | def default_config_path(config_file) 50 | ::File.expand_path("../../../config/#{config_file}", __FILE__) 51 | end 52 | 53 | def load_file(type) 54 | if @opts[type].nil? || !::File.exists?(@opts[type]) 55 | @opts[type] = default_config_path(FILE_NAME[type]) 56 | end 57 | eval "@#{type} = YAML.load_file(@opts[:#{type}])" 58 | end 59 | 60 | def initialize app, opts = {} 61 | @app = app 62 | @opts = opts 63 | 64 | FILE_TYPE.each do |type| 65 | load_file(type) 66 | end 67 | 68 | if @config['assertion_handler'].nil? 69 | raise ArgumentError, "'assertion_handler' parameter should be specified in the :config file" 70 | end 71 | end 72 | 73 | class Session 74 | RACK_SAML_COOKIE = '_rack_saml' 75 | def initialize(env) 76 | @rack_session = env['rack.session'] 77 | if @rack_session[RACK_SAML_COOKIE].nil? 78 | @session = @rack_session[RACK_SAML_COOKIE] = { 79 | 'ds.session' => {}, 80 | 'saml_authreq.session' => {}, 81 | 'saml_res.session' => {'env' => {}} 82 | } 83 | else 84 | @session = @rack_session[RACK_SAML_COOKIE] 85 | end 86 | end 87 | 88 | def generate_sid(length = 32) 89 | SecureRandom.hex(length) 90 | end 91 | 92 | def get_sid(type) 93 | @session["#{type}.session"]['sid'] 94 | end 95 | 96 | def start(type, timeout = 300) 97 | sid = nil 98 | if timeout.nil? 99 | period = Time.now + 300 100 | else 101 | period = Time.now + timeout 102 | end 103 | case type 104 | when 'ds' 105 | sid = generate_sid(4) 106 | when 'saml_authreq' 107 | sid = generate_sid 108 | when 'saml_res' 109 | sid = generate_sid 110 | end 111 | @session["#{type}.session"]['sid'] = sid 112 | @session["#{type}.session"]['expires'] = period.to_s 113 | @session["#{type}.session"] 114 | end 115 | 116 | def finish(type) 117 | @session["#{type}.session"] = {} 118 | end 119 | 120 | def env 121 | @session['saml_res.session']['env'] 122 | end 123 | 124 | def is_valid?(type, sid = nil) 125 | session = @session["#{type}.session"] 126 | return false if session['sid'].nil? # no valid session 127 | if session['expires'].nil? # no expiration 128 | return true if sid.nil? # no sid check 129 | return true if session['sid'] == sid # sid check 130 | else 131 | if Time.now < Time.parse(session['expires']) # before expiration 132 | return true if sid.nil? # no sid check 133 | return true if session['sid'] == sid # sid check 134 | end 135 | end 136 | false 137 | end 138 | end 139 | 140 | def call env 141 | session = Session.new(env) 142 | request = Rack::Request.new env 143 | # saml_sp: SAML SP's entity_id 144 | # generate saml_sp from request uri and default path (rack-saml-sp) 145 | saml_sp_prefix = "#{request.scheme}://#{request.host}#{":#{request.port}" if request.port}#{request.script_name}" 146 | @config['saml_sp'] ||= "#{saml_sp_prefix}/rack-saml-sp" 147 | @config['assertion_consumer_service_uri'] ||= "#{saml_sp_prefix}#{@config['protected_path']}" 148 | # for debug 149 | #return [ 150 | # 403, 151 | # { 152 | # 'Content-Type' => 'text/plain' 153 | # }, 154 | # ["Forbidden." + request.inspect] 155 | # ["Forbidden." + env.to_a.map {|i| "#{i[0]}: #{i[1]}"}.join("\n")] 156 | #] 157 | if request.request_method == 'GET' 158 | if match_protected_path?(request) # generate AuthnRequest 159 | if session.is_valid?('saml_res') # the client already has a valid session 160 | ResponseHandler.extract_attrs(env, session) 161 | else 162 | if !@config['shib_ds'].nil? # use discovery service (ds) 163 | if request.params['entityID'].nil? # start ds session 164 | session.start('ds') 165 | return Rack::Response.new.tap { |r| 166 | r.redirect "#{@config['shib_ds']}?entityID=#{URI.encode(@config['saml_sp'], /[^\w]/)}&return=#{URI.encode("#{@config['assertion_consumer_service_uri']}?target=#{session.get_sid('ds')}", /[^\w]/)}" 167 | }.finish 168 | end 169 | if !session.is_valid?('ds', request.params['target']) # confirm ds session 170 | current_sid = session.get_sid('ds') 171 | session.finish('ds') 172 | return create_response(500, 'text/html', "Internal Server Error: Invalid discovery service session current sid=#{current_sid}, request sid=#{request.params['target']}") 173 | end 174 | session.finish('ds') 175 | @config['saml_idp'] = request.params['entityID'] 176 | end 177 | session.start('saml_authreq') 178 | handler = RequestHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']]) 179 | return Rack::Response.new.tap { |r| 180 | r.redirect handler.authn_request.redirect_uri 181 | }.finish 182 | end 183 | elsif match_metadata_path?(request) # generate Metadata 184 | handler = MetadataHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']]) 185 | return create_response(200, 'application/samlmetadata+xml', handler.sp_metadata.generate) 186 | end 187 | elsif request.request_method == 'POST' && match_protected_path?(request) # process Response 188 | if session.is_valid?('saml_authreq') 189 | handler = ResponseHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']]) 190 | begin 191 | if handler.response.is_valid? 192 | session.finish('saml_authreq') 193 | session.start('saml_res', @config['saml_sess_timeout'] || 1800) 194 | handler.extract_attrs(env, session, @attribute_map) 195 | return Rack::Response.new.tap { |r| 196 | r.redirect request.url 197 | }.finish 198 | else 199 | return create_response(403, 'text/html', 'SAML Error: Invalid SAML response.') 200 | end 201 | rescue ValidationError => e 202 | return create_response(403, 'text/html', "SAML Error: Invalid SAML response.
Reason: #{e.message}") 203 | end 204 | else 205 | return create_response(500, 'text/html', 'No valid AuthnRequest session.') 206 | end 207 | end 208 | 209 | @app.call env 210 | end 211 | 212 | def match_protected_path?(request) 213 | request.path_info == @config['protected_path'] 214 | end 215 | 216 | def match_metadata_path?(request) 217 | request.path_info == @config['metadata_path'] 218 | end 219 | 220 | def create_response(code, content_type, message) 221 | return [ 222 | code, 223 | { 224 | 'Content-Type' => content_type 225 | }, 226 | [message] 227 | ] 228 | end 229 | 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rack::SAML, a SAML (Shibboleth) SP Rack middleware 2 | 3 | [![Gem Version](http://img.shields.io/gem/v/rack-saml.svg)](http://rubygems.org/gems/rack-saml) 4 | [![Build Status](https://travis-ci.org/toyokazu/rack-saml.svg?branch=master)](https://travis-ci.org/toyokazu/rack-saml) 5 | 6 | This project is deeply inspired by rack-shibboleth and ruby-saml. It is recommended to use the de facto SAML implementation such as OpenSAML from the security or the functional aspect. However, there are also requirements to use SAML for light weight applications implemented by Ruby. rack-shibboleth may be a candidate to support such kind of objective. However it lacks the configurability to fit OmniAuth and OmniAuth Shibboleth Strategy. It also lacks the upgrade path to the secure and the stable SAML implementation like OpenSAML. So rack-saml is implemented just a prototype Rack middleware. to support SAML (Shibboleth SP). 7 | 8 | OmniAuth Shibboleth Strategy 9 | https://github.com/toyokazu/omniauth-shibboleth 10 | 11 | rack-saml uses external libraries to generate and validate SAML AuthnRequest/Response. It uses basic Rack functions to implement SAML Transport (HTTP Redirect Binding and HTTP POST Binding). 12 | 13 | ## Changes 14 | 15 | * version 0.0.2: SP session is supported using Rack::Session for Rack applications and ActionDispatch::Session for Rails applications. 16 | * version 0.1.2: Update to fit newer ruby-saml. 17 | 18 | ## Limitations 19 | 20 | ### AuthnRequest Signing and Response Encryption 21 | 22 | Current implementation supports only Onelogin SAML assertion handler. It does not support to sign AuthnRequest and encrypt Response. So thus, the assertion encription function should be disabled at IdP side for rack-saml SPs. 23 | 24 | ## Getting Started 25 | 26 | ### Setup Gemfile and Installation 27 | 28 | % cd rails-app 29 | % vi Gemfile 30 | gem 'rack-saml' 31 | % bundle install 32 | 33 | ### Setup Rack::Saml middleware 34 | 35 | Rack::Saml uses Rack::Session functions. You have to insert Rack::Session before Rack::Saml middleware. Rack::Session::Cookie is used in the following examples because it is easiest to setup and scale. You can use the other Rack::Session implementation. In a Rails application, it uses ActionDispatch::Session which is compatible with Rack::Session by default. So thus, you do not need to add Rack::Session in the Rails application. 36 | 37 | **For Rack applicaitons** 38 | 39 | In the following example, config.ru is used to add Rack::Saml middleware into a Rails application. 40 | 41 | % vi config.ru 42 | use Rack::Session::Cookie, :secret => 'pass_to_auth_session' 43 | use Rack::Saml, {:config => "#{Rails.root}/config/rack-saml.yml", 44 | :metadata => "#{Rails.root}/config/metadata.yml", 45 | :attribute_map => "#{Rails.root}/config/attribute-map.yml"} 46 | 47 | **For Ralis applications** 48 | 49 | In the following example, config/application.rb is used to Rack::Saml middleware into a Rails application. 50 | 51 | % vi config/application.rb 52 | module TestRackSaml 53 | class Application < Rails::Application 54 | config.middleware.use Rack::Saml, {:config => "#{Rails.root}/config/rack-saml.yml", 55 | :metadata => "#{Rails.root}/config/metadata.yml", 56 | :attribute_map => "#{Rails.root}/config/attribute-map.yml"} 57 | ... 58 | 59 | If you like to add this middleware like OmniAuth (add configuration into the config/initializers directory), you can use the following. 60 | 61 | % vi config/initializers/rack_saml.rb 62 | Rails.application.config.middleware.insert_after Rack::ETag, Rack::Saml, 63 | {:config => "#{Rails.root}/config/rack-saml.yml", 64 | :metadata => "#{Rails.root}/config/metadata.yml", 65 | :attribute_map => "#{Rails.root}/config/attribute-map.yml"} 66 | 67 | If you use rack-saml with omniauth-shibboleth, Rack::Saml middleware must be loaded before OmniAuth::Builder. Thus, "insert_after Rack::ETag" is used in the above example. 68 | 69 | **Middleware options** 70 | 71 | * *:config*: path to rack-saml.yml file 72 | * *:metadata*: path to metadata.yml file 73 | * *:attribute_map*: path to attribute-map.yml file 74 | 75 | If you just want to test Rack::Saml, you can ommit middleware options in the both example (config.ru or config/application.rb). 76 | 77 | use Rack::Saml 78 | 79 | It may be useful for a tutorial use. At least, saml_idp or shib_ds in rack-saml.yml and metadata.yml must be configured to fit your environment. 80 | 81 | Rack::Saml uses default configurations located in the rack-saml gem path. 82 | 83 | $GEM_HOME/rack-saml-x.x.x/config/xxx.yml 84 | 85 | Please copy them to an arbitrary directory and edit them if you need. If you want to use your customized configuration file, do not forget to specify the configuration file path by middleware options. 86 | 87 | **Configuration files** 88 | 89 | You can find default configuration files at 90 | 91 | $GEM_HOME/rack-saml-x.x.x/config/xxx.yml 92 | 93 | **rack-saml.yml** 94 | 95 | Configuration to set SAML parameters. At least, you must configure saml_idp or shib_ds. They depends on your environments. 96 | 97 | * *protected_path*: path name where rack-saml protects, e.g. /auth/shibboleth/callback (default path for OmniAuth Shibboleth Strategy) 98 | * *metadata_path*: the path name where SP's metadata is generated 99 | * *assertion_handler*: 'onelogin' / 'opensaml' (not implemented yet) 100 | * *saml_idp*: IdP's entity ID which is used to authenticate user. This parameter can be omitted when you use Shibboleth Discovery Service (shib_ds). 101 | * *saml_sess_timeout*: SP session timeout (default: 1800 seconds) 102 | * *shib_app_id*: If you want to use the middleware as Shibboleth SP, you should specify an application ID. In the Shibboleth SP default configuration, 'default' is used as the application ID. 103 | * *shib_ds*: If you want to use the middleware as Shibboleth SP and use discovery service, specify the uri of the Discovery Service. 104 | * *saml_sp*: Set the SAML SP's entity ID 105 | * *sp_cert*: path to the SAML SP's certificate file, e.g. cert.pem (AuthnRequest Signing and Response Encryption are not supported yet) 106 | * *sp_key*: path to the SAML SP's key file, e.g. key.pem (AuthnRequest Signing and Response Encryption are not supported yet) 107 | * *allowed_clock_drift*: A clock margin (second) for checking NotBefore condition specified in a SAML Response (default: 0 seconds, 60 second may be good for local test). 108 | * *validation_error*: If set to true, a detailed reason of SAML response validation error will be shown on the browser (true/false) 109 | * *assertion_consumer_service_uri*: The URI for the SP's assertion consumer service. Automatically generated if not set (see below). *Note: If you have multiple sub-domains, it is recommended to set this URI explicitly otherwise the host set in the ensuing request may not match the assertion consumer URI set in the metadata which the IdP has for the SP* 110 | 111 | If not set explicitly, SAML SP's entity ID (saml_sp) is automatically generated from request URI and /rack-saml-sp (fixed path name). The Assertion Consumer Service URI is generated from request URI and protected_path. 112 | 113 | saml_sp_prefix = "#{request.scheme}://#{request.host}#{":#{request.port}" if request.port}#{request.script_name}" 114 | @config['saml_sp'] ||= "#{saml_sp_prefix}/rack-saml-sp" 115 | @config['assertion_consumer_service_uri'] ||= "#{saml_sp_prefix}#{@config['protected_path']}" 116 | 117 | **metadata.yml** 118 | 119 | To connect to an IdP, you must describe IdP's specification. In rack-saml, it should be written in metadata.yml. metadata.yml file include the following lists. You must generate your own metadata.yml by using conv_metadata.rb. 120 | 121 | * *idp_lists*: list of IdP metadata 122 | * *sp_lists*: list of SP metadata 123 | 124 | idp_lists and sp_lists are hashes which have entity ids as key values. 125 | 126 | parameters of the idp_lists: 127 | 128 | * *certificate*: base64 encoded certificate of IdP 129 | * *saml2_http_redirect*: Location attribute of the IdP's assertion handler uri with HTTP Redirect Binding 130 | 131 | parameters of the sp_lists (currently not used): 132 | 133 | * *certificate*: base64 encoded certificate of SP 134 | * *saml2_http_post*: Location attribute of the SP's assertion consumer uri with HTTP POST Binding 135 | 136 | These parameters are automatically extracted from SAML metadata (XML). You can use conv_metadata.rb command for extraction. 137 | 138 | % $GEM_HOME/rack-saml-x.x.x/bin/conv_metadata.rb metadata.xml > metadata.yml 139 | 140 | **attribute-map.yml** 141 | 142 | attribute-map.yml can extract attributes from SAML Response and put attributes on request environment variables. It is useful to pass attributes into applications. The configuration file format is as follows: 143 | 144 | "Attribute Name": "Environment Variable Name" 145 | "urn:oid:0.9.2342.19200300.100.1.1": "uid" 146 | ... 147 | 148 | You can use default attribute-map.yml file. If you want to add new attributes, please refer the attribute-map.xml file used in Shibboleth SP. 149 | 150 | ### Setup IdP to accept rack-saml SP 151 | 152 | **SP Metadata generation** 153 | 154 | To connect a new SP to the existing IdP, you need to import SP's metadata into the IdP. rack-saml provides metadata generation function. It is generated at '/Shibboleth.sso/Metadata' by default. 155 | 156 | **IdP configuration examples not to encrypt assertion** 157 | 158 | Current rack-saml implementation does not support assertion encryption because OneLogin::RubySaml does not support AuthnRequest signing and Response encryption. So thus, in the followings, we would like to show sample configurations to disable encryption in IdP assertion processing. These are not recommended for sensitive applications. 159 | 160 | **Shibboleth IdP example** 161 | 162 | Add the following configuration after in relying-party.xml. You should specify sp entity id at the 'id' and the 'provider' attributes. 163 | 164 | % vi $IDP_HOME/conf/relying-party.xml 165 | ... 166 | 167 | 168 | 169 | 170 | ## Advanced Topics 171 | 172 | ### Use with OmniAuth 173 | 174 | You can connect rack-saml to omniauth-shibboleth. Basically, you do not need any specific configuration to use with omniauth-shibboleth. 175 | 176 | ### Use with Devise 177 | 178 | You can connect rack-saml to devise by using it together with omniauth and omniauth-shibboleth. The details of how to connect omniauth and devise are described in the following page: 179 | 180 | OmniAuth: Overview 181 | https://github.com/plataformatec/devise/wiki/OmniAuth:-Overview 182 | 183 | When you use omniauth with devise, the omniauth provider path becomes "/users/auth/shibboleth". So thus, you must set the *protected_path* parameter as "/users/auth/shibboleth/callback". After changing the configuration, you must also re-generate SP Metadata (/Shibboleth.sso/Metadata) and import it to IdP because ** parameter in SP Metadata is generated by the *protected_path* parameter. 184 | 185 | ## TODO 186 | 187 | * write spec files 188 | * ruby-opensaml (I hope someone implement it :) 189 | 190 | ## License (MIT License) 191 | 192 | rack-saml is released under the MIT license. 193 | 194 | Permission is hereby granted, free of charge, to any person obtaining a copy 195 | of this software and associated documentation files (the "Software"), to deal 196 | in the Software without restriction, including without limitation the rights 197 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 198 | copies of the Software, and to permit persons to whom the Software is 199 | furnished to do so, subject to the following conditions: 200 | 201 | The above copyright notice and this permission notice shall be included in 202 | all copies or substantial portions of the Software. 203 | 204 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 205 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 206 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 207 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 208 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 209 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 210 | THE SOFTWARE. 211 | --------------------------------------------------------------------------------