├── .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 | [](http://rubygems.org/gems/rack-saml)
4 | [](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 |
--------------------------------------------------------------------------------