stub_saml_idp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a3f6ac356bfc0056773af0a5521b971242a78dcae4988d5dd1a11e7c185457fc
4
+ data.tar.gz: 4e0cee21a7d101f93279a6c3692a5e632c9847ae55a0ab0be1d1cab6b8a603b8
5
+ SHA512:
6
+ metadata.gz: a5719837b97827677c41461a987899847460ab0904092e98962320d19144b550df1426b8ea7be09bc1120e0ec0ee3c58a5879c31c95f8c954dd77177c072b82e
7
+ data.tar.gz: 345e07b9e5da8a4f31c82954a9cf564402911d995e4da75d105ef3d5cefb1deb880820d91aed2ac61cdbdede20505a67cdd8fe2c10fb1cc9f097ac37657b6dc1
data/Gemfile ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem 'rails', '~> 7.0.0'
9
+ gem 'rubocop'
10
+ gem 'rubocop-rspec'
11
+ gem 'ruby-saml'
12
+ end
13
+
14
+ group :test do
15
+ gem 'capybara'
16
+ gem 'rake'
17
+ gem 'rspec', '~> 3.0'
18
+ gem 'rspec-rails', '~> 5.0'
19
+ gem 'selenium-webdriver'
20
+ gem 'timecop'
21
+ end
22
+
23
+ if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('3.1')
24
+ gem 'net-imap', require: false
25
+ gem 'net-pop', require: false
26
+ gem 'net-smtp', require: false
27
+ end
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ Copyright (c) 2022 Peter M. Goldstein (https://github.com/petergoldstein/stub_saml_idp)
2
+ Copyright (c) 2012 Lawrence Pit (https://github.com/lawrencepit/ruby-saml-idp)
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining
5
+ a copy of this software and associated documentation files (the
6
+ "Software"), to deal in the Software without restriction, including
7
+ without limitation the rights to use, copy, modify, merge, publish,
8
+ distribute, sublicense, and/or sell copies of the Software, and to
9
+ permit persons to whom the Software is furnished to do so, subject to
10
+ the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # Stub SAML Identity Provider (IdP)
2
+
3
+ The Stub SAML Identity Provider library allows users to easily spin up stub SAML IdP
4
+ servers in test environments.
5
+
6
+ This is not a "real" IdP and should not be used in production environments. It is intended
7
+ only for use in testing environments.
8
+
9
+
10
+ Installation and Usage
11
+ ----------------------
12
+
13
+ Add this to the Gemfile of your Rails app in your test environment:
14
+
15
+ gem 'stub_saml_idp'
16
+
17
+ Add to your `routes.rb` file, for example:
18
+
19
+ ``` ruby
20
+ get '/saml/auth' => 'saml_idp#new'
21
+ post '/saml/auth' => 'saml_idp#create'
22
+ ```
23
+
24
+ Create a controller that looks like this, customize to your own situation:
25
+
26
+ ``` ruby
27
+ class SamlIdpController < StubSamlIdp::IdpController
28
+ before_action :find_account
29
+ # layout 'saml_idp'
30
+
31
+ def idp_authenticate(email, password)
32
+ user = @account.users.where(:email => params[:email]).first
33
+ user && user.valid_password?(params[:password]) ? user : nil
34
+ end
35
+
36
+ def idp_make_saml_response(user)
37
+ encode_SAMLResponse(user.email)
38
+ end
39
+
40
+ private
41
+
42
+ def find_account
43
+ @subdomain = saml_acs_url[/https?:\/\/(.+?)\.example.com/, 1]
44
+ @account = Account.find_by_subdomain(@subdomain)
45
+ render :status => :forbidden unless @account.saml_enabled?
46
+ end
47
+
48
+ end
49
+ ```
50
+
51
+ The most minimal example controller would look like:
52
+
53
+ ``` ruby
54
+ class SamlIdpController < StubSamlIdp::IdpController
55
+
56
+ def idp_authenticate(email, password)
57
+ true
58
+ end
59
+
60
+ def idp_make_saml_response(user)
61
+ encode_SAMLResponse("you@example.com")
62
+ end
63
+
64
+ end
65
+ ```
66
+
67
+ Keys and Secrets
68
+ ----------------
69
+
70
+ To generate the SAML Response it uses a default X.509 certificate and secret key... which isn't so secret. You can find them in `SamlIdp::Default`. The X.509 certificate is valid until year 2032. You can customize these values by setting the properties `x509_certificate` and `secret_key` using a `prepend_before_action` callback within the current request context or setting them globally via the `SamlIdp.config.x509_certificate` and `SamlIdp.config.secret_key` properties.
71
+
72
+ The fingerprint to use, if you use the default X.509 certificate of this gem, is:
73
+
74
+ ```
75
+ 9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D
76
+ ```
77
+
78
+
79
+ Service Providers
80
+ -----------------
81
+
82
+ To act as a Service Provider which generates SAML Requests and can react to SAML Responses use the excellent [ruby-saml](https://github.com/onelogin/ruby-saml) gem.
83
+
84
+
85
+ Contributors
86
+ -------------
87
+
88
+ This is an updated version of the stub SAML IDP originally published by [Lawrence Pit](https://github.com/lawrencepit). The updated gem would not have been possible without his contribution.
89
+
90
+ Copyright
91
+ -----------
92
+
93
+ Copyright (c) 2022 Peter M. Goldstein See MIT-LICENSE for details.
94
+ Copyright (c) 2012 Lawrence Pit. See MIT-LICENSE for details.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ class IdpController < ActionController::Base
5
+ include StubSamlIdp::Controller
6
+
7
+ protect_from_forgery
8
+
9
+ before_action :validate_saml_request
10
+
11
+ def new
12
+ render template: 'stub_saml_idp/idp/new'
13
+ end
14
+
15
+ def create
16
+ render_no_params && return unless auth_params?
17
+
18
+ if person.nil?
19
+ render_auth_failure
20
+ else
21
+ @saml_response = idp_make_saml_response(person)
22
+ render template: 'stub_saml_idp/idp/saml_post', layout: false
23
+ end
24
+ end
25
+
26
+ def render_no_params
27
+ render template: 'stub_saml_idp/idp/new'
28
+ end
29
+
30
+ def render_auth_failure
31
+ @saml_idp_fail_msg = 'Incorrect email or password.'
32
+ render template: 'stub_saml_idp/idp/new'
33
+ end
34
+
35
+ def person
36
+ return nil unless auth_params?
37
+
38
+ @person ||= idp_authenticate(params[:email], params[:password])
39
+ end
40
+
41
+ def auth_params?
42
+ !(params[:email].blank? || params[:password].blank?)
43
+ end
44
+
45
+ protected
46
+
47
+ def idp_authenticate(_email, _password)
48
+ raise 'Not implemented'
49
+ end
50
+
51
+ def idp_make_saml_response(_person)
52
+ raise 'Not implemented'
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ <% if @saml_idp_fail_msg %>
2
+ <div id="saml_idp_fail_msg" class="flash error"><%= @saml_idp_fail_msg %></div>
3
+ <% end %>
4
+
5
+ <%= form_tag do %>
6
+ <%= hidden_field_tag("SAMLRequest", params[:SAMLRequest]) %>
7
+
8
+ <p>
9
+ <%= label_tag :email %>
10
+ <%= email_field_tag :email, params[:email], :autocapitalize => "off", :autocorrect => "off", :autofocus => "autofocus", :spellcheck => "false", :size => 30, :class => "email_pwd txt" %>
11
+ </p>
12
+
13
+ <p>
14
+ <%= label_tag :password %>
15
+ <%= password_field_tag :password, params[:password], :autocapitalize => "off", :autocorrect => "off", :spellcheck => "false", :size => 30, :class => "email_pwd txt" %>
16
+ </p>
17
+
18
+ <p>
19
+ <%= submit_tag "Sign in", :class => "button big blueish" %>
20
+ </p>
21
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
6
+ </head>
7
+ <body onload="document.forms[0].submit();" style="visibility:hidden;">
8
+ <%= form_tag(@saml_acs_url) do %>
9
+ <%= hidden_field_tag("SAMLResponse", @saml_response) %>
10
+ <%= submit_tag "Submit" %>
11
+ <% end %>
12
+ </body>
13
+ </html>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ class Configurator
5
+ attr_accessor :x509_certificate, :secret_key, :algorithm, :expires_in
6
+
7
+ def initialize(config_file = nil)
8
+ self.x509_certificate = Default::X509_CERTIFICATE
9
+ self.secret_key = Default::SECRET_KEY
10
+ self.algorithm = :sha1
11
+ self.expires_in = nil
12
+ instance_eval(File.read(config_file), config_file) if config_file
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'time'
6
+ require 'securerandom'
7
+
8
+ module StubSamlIdp
9
+ module Controller
10
+ attr_accessor :saml_acs_url
11
+ attr_writer :expires_in, :secret_key, :x509_certificate
12
+
13
+ def x509_certificate
14
+ @x509_certificate ||= StubSamlIdp.config.x509_certificate
15
+ end
16
+
17
+ def secret_key
18
+ @secret_key ||= StubSamlIdp.config.secret_key
19
+ end
20
+
21
+ def algorithm
22
+ @algorithm ||= algorithm_from_symbol(StubSamlIdp.config.algorithm)
23
+ end
24
+
25
+ def algorithm=(alg)
26
+ @algorithm = if alg.is_a?(Symbol)
27
+ algorithm_from_symbol(alg)
28
+ else
29
+ alg
30
+ end
31
+ end
32
+
33
+ def algorithm_from_symbol(alg_sym = nil)
34
+ case alg_sym
35
+ when :sha256 then OpenSSL::Digest::SHA256
36
+ when :sha384 then OpenSSL::Digest::SHA384
37
+ when :sha512 then OpenSSL::Digest::SHA512
38
+ else
39
+ OpenSSL::Digest::SHA1
40
+ end
41
+ end
42
+
43
+ def algorithm_name
44
+ algorithm.to_s.split('::').last.downcase
45
+ end
46
+
47
+ def expires_in
48
+ return @expires_in if defined?(@expires_in)
49
+
50
+ @expires_in ||= StubSamlIdp.config.expires_in
51
+ end
52
+
53
+ protected
54
+
55
+ def validate_saml_request(saml_request = params[:SAMLRequest])
56
+ decode_SAMLRequest(saml_request)
57
+ rescue StandardError
58
+ false
59
+ end
60
+
61
+ def decode_SAMLRequest(saml_request)
62
+ zstream = Zlib::Inflate.new(-Zlib::MAX_WBITS)
63
+ @saml_request = zstream.inflate(Base64.decode64(saml_request))
64
+ zstream.finish
65
+ zstream.close
66
+ @saml_request_id = @saml_request[/ID=['"](.+?)['"]/, 1]
67
+ @saml_acs_url = @saml_request[/AssertionConsumerServiceURL=['"](.+?)['"]/, 1]
68
+ end
69
+
70
+ def encode_SAMLResponse(name_id, opts = {})
71
+ now = Time.now.utc
72
+ response_id = SecureRandom.uuid
73
+ reference_id = SecureRandom.uuid
74
+ audience_uri = opts[:audience_uri] || saml_acs_url[%r{^(.*?//.*?/)}, 1]
75
+ issuer_uri = opts[:issuer_uri] || (defined?(request) && request.url) || 'http://example.com'
76
+ attributes_statement = attributes(opts[:attributes_provider], name_id)
77
+
78
+ session_expiration = ''
79
+ session_expiration = %( SessionNotOnOrAfter="#{(now + expires_in).iso8601}") if expires_in
80
+
81
+ assertion = %(<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_#{reference_id}" IssueInstant="#{now.iso8601}" Version="2.0"><saml:Issuer Format="urn:oasis:names:SAML:2.0:nameid-format:entity">#{issuer_uri}</saml:Issuer><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">#{name_id}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData#{@saml_request_id ? %( InResponseTo="#{@saml_request_id}") : ''} NotOnOrAfter="#{(now + (3 * 60)).iso8601}" Recipient="#{@saml_acs_url}"></saml:SubjectConfirmationData></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="#{(now - 5).iso8601}" NotOnOrAfter="#{(now + (60 * 60)).iso8601}"><saml:AudienceRestriction><saml:Audience>#{audience_uri}</saml:Audience></saml:AudienceRestriction></saml:Conditions>#{attributes_statement}<saml:AuthnStatement AuthnInstant="#{now.iso8601}" SessionIndex="_#{reference_id}"#{session_expiration}><saml:AuthnContext><saml:AuthnContextClassRef>urn:federation:authentication:windows</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement></saml:Assertion>)
82
+
83
+ digest_value = Base64.encode64(algorithm.digest(assertion)).gsub(/\n/, '')
84
+
85
+ signed_info = %(<ds:SignedInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:CanonicalizationMethod><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-#{algorithm_name}"></ds:SignatureMethod><ds:Reference URI="#_#{reference_id}"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"></ds:Transform><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig##{algorithm_name}"></ds:DigestMethod><ds:DigestValue>#{digest_value}</ds:DigestValue></ds:Reference></ds:SignedInfo>)
86
+
87
+ signature_value = sign(signed_info).gsub(/\n/, '')
88
+
89
+ signature = %(<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">#{signed_info}<ds:SignatureValue>#{signature_value}</ds:SignatureValue><KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#"><ds:X509Data><ds:X509Certificate>#{x509_certificate}</ds:X509Certificate></ds:X509Data></KeyInfo></ds:Signature>)
90
+
91
+ assertion_and_signature = assertion.sub(/Issuer><saml:Subject/, "Issuer>#{signature}<saml:Subject")
92
+
93
+ xml = %(<samlp:Response ID="_#{response_id}" Version="2.0" IssueInstant="#{now.iso8601}" Destination="#{@saml_acs_url}" Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified"#{@saml_request_id ? %( InResponseTo="#{@saml_request_id}") : ''} xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">#{issuer_uri}</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" /></samlp:Status>#{assertion_and_signature}</samlp:Response>)
94
+
95
+ Base64.encode64(xml)
96
+ end
97
+
98
+ private
99
+
100
+ def sign(data)
101
+ key = OpenSSL::PKey::RSA.new(secret_key)
102
+ Base64.encode64(key.sign(algorithm.new, data))
103
+ end
104
+
105
+ def attributes(provider, name_id)
106
+ provider || %(<saml:AttributeStatement><saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"><saml:AttributeValue>#{name_id}</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>)
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ module Default
5
+ NAME_ID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
6
+
7
+ X509_CERTIFICATE = 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURxekNDQXhTZ0F3SUJBZ0lCQVRBTkJna3Foa2lHOXcwQkFRc0ZBRENCaGpFTE1Ba0dBMVVFQmhNQ1FWVXgKRERBS0JnTlZCQWdUQTA1VFZ6RVBNQTBHQTFVRUJ4TUdVM2xrYm1WNU1Rd3dDZ1lEVlFRS0RBTlFTVlF4Q1RBSApCZ05WQkFzTUFERVlNQllHQTFVRUF3d1BiR0YzY21WdVkyVndhWFF1WTI5dE1TVXdJd1lKS29aSWh2Y05BUWtCCkRCWnNZWGR5Wlc1alpTNXdhWFJBWjIxaGFXd3VZMjl0TUI0WERURXlNRFF5T0RBeU1qSXlPRm9YRFRNeU1EUXkKTXpBeU1qSXlPRm93Z1lZeEN6QUpCZ05WQkFZVEFrRlZNUXd3Q2dZRFZRUUlFd05PVTFjeER6QU5CZ05WQkFjVApCbE41Wkc1bGVURU1NQW9HQTFVRUNnd0RVRWxVTVFrd0J3WURWUVFMREFBeEdEQVdCZ05WQkFNTUQyeGhkM0psCmJtTmxjR2wwTG1OdmJURWxNQ01HQ1NxR1NJYjNEUUVKQVF3V2JHRjNjbVZ1WTJVdWNHbDBRR2R0WVdsc0xtTnYKYlRDQm56QU5CZ2txaGtpRzl3MEJBUUVGQUFPQmpRQXdnWWtDZ1lFQXVCeXdQTmxDMUZvcEdMWWZGOTZTb3RpSwo4Tmo2L25XMDg0TzRvbVJNaWZ6eTd4OTU1UkxFeTY3M3EyYWlKTkIzTHZFNlh2a3Q5Y0d0eHROb09YdzFnMlV2CkhLcGxkUWJyNmJPRWpMTmVETlc3ajBvYitKclJ2QVVPSzlDUmdkeXc1TUM2bHdxVlFRNUMxRG5hVC8yZlNCRmoKYXNCRlRSMjRkRXBmVHk4SGZLRUNBd0VBQWFPQ0FTVXdnZ0VoTUFrR0ExVWRFd1FDTUFBd0N3WURWUjBQQkFRRApBZ1VnTUIwR0ExVWREZ1FXQkJRTkJHbW10M3l0S3BjSmFCYVlOYm55VTJ4a2F6QVRCZ05WSFNVRUREQUtCZ2dyCkJnRUZCUWNEQVRBZEJnbGdoa2dCaHZoQ0FRMEVFQllPVkdWemRDQllOVEE1SUdObGNuUXdnYk1HQTFVZEl3U0IKcXpDQnFJQVVEUVJwcHJkOHJTcVhDV2dXbURXNThsTnNaR3VoZ1l5a2dZa3dnWVl4Q3pBSkJnTlZCQVlUQWtGVgpNUXd3Q2dZRFZRUUlFd05PVTFjeER6QU5CZ05WQkFjVEJsTjVaRzVsZVRFTU1Bb0dBMVVFQ2d3RFVFbFVNUWt3CkJ3WURWUVFMREFBeEdEQVdCZ05WQkFNTUQyeGhkM0psYm1ObGNHbDBMbU52YlRFbE1DTUdDU3FHU0liM0RRRUoKQVF3V2JHRjNjbVZ1WTJVdWNHbDBRR2R0WVdsc0xtTnZiWUlCQVRBTkJna3Foa2lHOXcwQkFRc0ZBQU9CZ1FBRQpjVlVQQlg3dVptenFaSmZ5K3RVUE9UNUltTlFqOFZFMmxlcmhuRmpuR1BIbUhJcWhwemdud0hRdWpKZnMvYTMwCjlXbTVxd2NDYUMxZU81Y1dqY0cweDNPamRsbHNnWURhdGw1R0F1bXRCeDhKM05oV1JxTlVnaXRDSWtRbHhISXcKVWZnUWFDdXNoWWdEREw1WWJJUWErK2VnQ2dwSVorVDBEajVvUmV3Ly9BPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='
8
+
9
+ FINGERPRINT = '9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D'
10
+
11
+ SECRET_KEY = <<~PEM
12
+ -----BEGIN RSA PRIVATE KEY-----
13
+ MIICXAIBAAKBgQC4HLA82ULUWikYth8X3pKi2Irw2Pr+dbTzg7iiZEyJ/PLvH3nl
14
+ EsTLrverZqIk0Hcu8Tpe+S31wa3G02g5fDWDZS8cqmV1Buvps4SMs14M1buPShv4
15
+ mtG8BQ4r0JGB3LDkwLqXCpVBDkLUOdpP/Z9IEWNqwEVNHbh0Sl9PLwd8oQIDAQAB
16
+ AoGAQmUGIUtwUEgbXe//kooPc26H3IdDLJSiJtcvtFBbUb/Ik/dT7AoysgltA4DF
17
+ pGURNfqERE+0BVZNJtCCW4ixew4uEhk1XowYXHCzjkzyYoFuT9v5SP4cu4q3t1kK
18
+ 51JF237F0eCY3qC3k96CzPGG67bwOu9EeXAu4ka/plLdsAECQQDkg0uhR/vsJffx
19
+ tiWxcDRNFoZpCpzpdWfQBnHBzj9ZC0xrdVilxBgBpupCljO2Scy4MeiY4S1Mleel
20
+ CWRqh7RBAkEAzkIjUnllEkr5sjVb7pNy+e/eakuDxvZck0Z8X3USUki/Nm3E/GPP
21
+ c+CwmXR4QlpMpJr3/Prf1j59l/LAK9AwYQJBAL9eRSQYCJ3HXlGKXR6v/NziFEY7
22
+ oRTSQdIw02ueseZ8U89aQpbwFbqsclq5Ny1duJg5E7WUPj94+rl3mCSu6QECQBVh
23
+ 0duY7htpXl1VHsSq0H6MmVgXn/+eRpaV9grHTjDtjbUMyCEKD9WJc4VVB6qJRezC
24
+ i/bT4ySIsehwp+9i08ECQEH03lCpHpbwiWH4sD25l/z3g2jCbIZ+RTV6yHIz7Coh
25
+ gAbBqA04wh64JhhfG69oTBwqwj3imlWF8+jDzV9RNNw=
26
+ -----END RSA PRIVATE KEY-----
27
+ PEM
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ class Engine < Rails::Engine
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StubSamlIdp
4
+ require 'stub_saml_idp/configurator'
5
+ require 'stub_saml_idp/controller'
6
+ require 'stub_saml_idp/default'
7
+ require 'stub_saml_idp/version'
8
+ require 'stub_saml_idp/engine' if defined?(::Rails) && Rails::VERSION::MAJOR > 2
9
+
10
+ def self.config=(config)
11
+ @config = config
12
+ end
13
+
14
+ def self.config
15
+ @config ||= StubSamlIdp::Configurator.new
16
+ end
17
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../spec_helper'
4
+ require_relative '../support/rails_app'
5
+ require 'rails'
6
+
7
+ require 'selenium-webdriver'
8
+
9
+ Capybara.register_driver :chrome do |app|
10
+ options = Selenium::WebDriver::Chrome::Options.new
11
+ options.add_argument('--headless')
12
+ options.add_argument('--allow-insecure-localhost')
13
+ options.add_argument('--ignore-certificate-errors')
14
+
15
+ Capybara::Selenium::Driver.new(
16
+ app,
17
+ browser: :chrome,
18
+ capabilities: [options]
19
+ )
20
+ end
21
+ Capybara.default_driver = :chrome
22
+ Capybara.server = :webrick
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'acceptance_helper'
4
+
5
+ describe 'IdpController', type: :feature do
6
+ let(:idp_port) { 8009 }
7
+ let(:sp_port) { 8022 }
8
+
9
+ let(:idp_pid) do
10
+ create_app('idp')
11
+ start_app('idp', idp_port)
12
+ end
13
+
14
+ let(:sp_pid) do
15
+ create_app('sp')
16
+ start_app('sp', sp_port)
17
+ end
18
+
19
+ before do
20
+ idp_pid
21
+ sp_pid
22
+ end
23
+
24
+ after do
25
+ stop_app('sp', sp_pid)
26
+ stop_app('idp', idp_pid)
27
+ end
28
+
29
+ it 'Login via default signup page' do
30
+ saml_request = make_saml_request("http://localhost:#{sp_port}/saml/consume")
31
+ visit "http://localhost:#{idp_port}/saml/auth?SAMLRequest=#{CGI.escape(saml_request)}"
32
+ fill_in 'Email', with: 'brad.copa@example.com'
33
+ fill_in 'Password', with: 'okidoki'
34
+ click_button 'Sign in'
35
+ expect(current_url).to eq("http://localhost:#{sp_port}/saml/consume")
36
+ expect(page).to have_content('brad.copa@example.com')
37
+ end
38
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './spec_helper'
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ warn("Running Specs under Ruby Version #{RUBY_VERSION}")
4
+
5
+ require 'rspec'
6
+ require 'capybara/rspec'
7
+
8
+ require 'ruby-saml'
9
+ require 'stub_saml_idp'
10
+ require 'support/saml_request_macros'
11
+
12
+ Capybara.default_host = 'https://app.example.com'
13
+
14
+ RSpec.configure do |config|
15
+ config.mock_with :rspec
16
+ config.include SamlRequestMacros
17
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'timecop'
5
+
6
+ describe StubSamlIdp::Controller do
7
+ include StubSamlIdp::Controller
8
+
9
+ def params
10
+ @params ||= {}
11
+ end
12
+
13
+ it 'finds the SAML ACS URL' do
14
+ requested_saml_acs_url = 'https://example.com/saml/consume'
15
+ params[:SAMLRequest] = make_saml_request(requested_saml_acs_url)
16
+ validate_saml_request
17
+ expect(saml_acs_url).to eq(requested_saml_acs_url)
18
+ end
19
+
20
+ context 'SAML Responses' do
21
+ before do
22
+ params[:SAMLRequest] = make_saml_request
23
+ validate_saml_request
24
+ end
25
+
26
+ it 'creates a SAML Response' do
27
+ saml_response = encode_SAMLResponse('foo@example.com')
28
+ response = OneLogin::RubySaml::Response.new(saml_response)
29
+ expect(response.name_id).to eq('foo@example.com')
30
+ expect(response.issuers).to eq(['http://example.com'])
31
+ response.settings = saml_settings
32
+ expect(response.is_valid?).to be_truthy
33
+ end
34
+
35
+ it 'handles custom attribute objects' do
36
+ provider = double(to_s: %(<saml:AttributeStatement><saml:Attribute Name="organization"><saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">Organization name</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>))
37
+
38
+ default_attributes = %(<saml:AttributeStatement><saml:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"><saml:AttributeValue>foo@example.com</saml:AttributeValue></saml:Attribute></saml:AttributeStatement>)
39
+
40
+ saml_response = encode_SAMLResponse('foo@example.com', { attributes_provider: provider })
41
+ response = OneLogin::RubySaml::Response.new(saml_response)
42
+ expect(response.response).to include provider.to_s
43
+ expect(response.response).not_to include default_attributes
44
+ end
45
+
46
+ %i[sha1 sha256 sha384 sha512].each do |algorithm_name|
47
+ it "creates a SAML Response using the #{algorithm_name} algorithm" do
48
+ self.algorithm = algorithm_name
49
+ saml_response = encode_SAMLResponse('foo@example.com')
50
+ response = OneLogin::RubySaml::Response.new(saml_response)
51
+ expect(response.name_id).to eq('foo@example.com')
52
+ expect(response.issuers).to eq(['http://example.com'])
53
+ response.settings = saml_settings
54
+ expect(response.is_valid?).to be true
55
+ end
56
+ end
57
+
58
+ it 'does not set SessionNotOnOrAfter when expires_in is nil' do
59
+ Timecop.freeze
60
+ self.expires_in = nil
61
+ saml_response = encode_SAMLResponse('foo@example.com')
62
+ response = OneLogin::RubySaml::Response.new(saml_response)
63
+ expect(response.session_expires_at).to be_nil
64
+ end
65
+
66
+ it 'sets SessionNotOnOrAfter when expires_in is specified' do
67
+ self.expires_in = 86_400 # 1 day
68
+ now = Time.now.utc
69
+ saml_response = Timecop.freeze(now) { encode_SAMLResponse('foo@example.com') }
70
+ response = OneLogin::RubySaml::Response.new(saml_response)
71
+ expect(response.session_expires_at).to eq(Time.at(now.to_i + 86_400))
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Set up a SAML IdP
4
+
5
+ gem 'stub_saml_idp', path: File.expand_path('../..', __dir__)
6
+
7
+ if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('3.1')
8
+ gem 'net-smtp', require: false
9
+ gem 'net-imap', require: false
10
+ gem 'net-pop', require: false
11
+ end
12
+
13
+ route "get '/saml/auth' => 'saml_idp#new'"
14
+ route "post '/saml/auth' => 'saml_idp#create'"
15
+
16
+ file 'app/controllers/saml_idp_controller.rb', <<-CODE
17
+ # frozen_string_literal: true
18
+
19
+ class SamlIdpController < StubSamlIdp::IdpController
20
+ def idp_authenticate(email, _password)
21
+ { email: email }
22
+ end
23
+
24
+ def idp_make_saml_response(user)
25
+ encode_SAMLResponse(user[:email])
26
+ end
27
+ end
28
+ CODE
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'open3'
5
+ require 'socket'
6
+ require 'tempfile'
7
+ require 'timeout'
8
+
9
+ APP_READY_TIMEOUT = 30
10
+
11
+ def sh!(cmd)
12
+ raise "[#{cmd}] failed with exit code #{$CHILD_STATUS.exitstatus}" unless system(cmd)
13
+ end
14
+
15
+ def app_ready?(pid, port)
16
+ Process.getpgid(pid) && port_open?(port)
17
+ rescue Errno::ESRCH
18
+ false
19
+ end
20
+
21
+ def create_app(name = 'idp', env = {})
22
+ puts "[#{name}] Creating Rails app"
23
+ rails_new_options = %w[-A -C -G -J -M -S -T --skip-keeps --skip-spring --skip-listen --skip-bootsnap --skip-action-mailbox --skip-action-text --skip-active-job --skip-active-storage --skip-hotwire --skip-jbuilder]
24
+ env.merge!('RUBY_SAML_VERSION' => OneLogin::RubySaml::VERSION)
25
+ Dir.chdir(working_directory) do
26
+ FileUtils.rm_rf(name)
27
+ puts("[#{working_directory}] rails _#{Rails.version}_ new #{name} #{rails_new_options.join(' ')} -m #{File.expand_path(
28
+ "../#{name}_template.rb", __FILE__
29
+ )}")
30
+ system(env, 'rails', "_#{Rails.version}_", 'new', name, *rails_new_options, '-m',
31
+ File.expand_path("../#{name}_template.rb", __FILE__))
32
+ end
33
+ end
34
+
35
+ def start_app(name, port, _options = {})
36
+ puts "[#{name}] Starting Rails app"
37
+ pid = nil
38
+ app_bundle_install(name)
39
+
40
+ with_clean_env do
41
+ Dir.chdir(app_dir(name)) do
42
+ pid = Process.spawn(app_env(name), "bundle exec rails server -p #{port} -e production", chdir: app_dir(name),
43
+ out: "log/#{name}.log", err: "log/#{name}.err.log")
44
+ begin
45
+ Timeout.timeout(APP_READY_TIMEOUT) do
46
+ sleep 1 until app_ready?(pid, port)
47
+ end
48
+ raise "#{name} failed after starting" unless app_ready?(pid, port)
49
+
50
+ puts "[#{name}] Launched #{name} on port #{port} (pid #{pid})..."
51
+ rescue Timeout::Error
52
+ raise "#{name} failed to start"
53
+ end
54
+ end
55
+ end
56
+ pid
57
+ rescue RuntimeError => e
58
+ warn "=== #{name}"
59
+ Dir.chdir(app_dir(name)) do
60
+ warn File.read("log/#{name}.log") if File.exist?("log/#{name}.log")
61
+ warn File.read("log/#{name}.err.log") if File.exist?("log/#{name}.err.log")
62
+ end
63
+ raise e
64
+ end
65
+
66
+ def stop_app(name, pid)
67
+ if pid
68
+ Process.kill(:INT, pid)
69
+ Process.wait(pid)
70
+ end
71
+ Dir.chdir(app_dir(name)) do
72
+ if File.exist?("log/#{name}.log")
73
+ puts "=== [#{name}] stdout"
74
+ puts File.read("log/#{name}.log")
75
+ end
76
+ if File.exist?("log/#{name}.err.log")
77
+ warn "=== [#{name}] stderr"
78
+ warn File.read("log/#{name}.err.log")
79
+ end
80
+ if File.exist?('log/production.log')
81
+ puts "=== [#{name}] Rails logs"
82
+ puts File.read('log/production.log')
83
+ end
84
+ end
85
+ end
86
+
87
+ def port_open?(port)
88
+ Timeout.timeout(1) do
89
+ begin
90
+ s = TCPSocket.new('localhost', port)
91
+ s.close
92
+ return true
93
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::EADDRNOTAVAIL
94
+ # try 127.0.0.1
95
+ end
96
+ begin
97
+ s = TCPSocket.new('127.0.0.1', port)
98
+ s.close
99
+ return true
100
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
101
+ return false
102
+ end
103
+ end
104
+ rescue Timeout::Error
105
+ false
106
+ end
107
+
108
+ def app_bundle_install(name)
109
+ with_clean_env do
110
+ Open3.popen3(app_env(name), 'bundle install', chdir: app_dir(name)) do |stdin, stdout, stderr, thread|
111
+ stdin.close
112
+ exit_status = thread.value
113
+
114
+ puts stdout.read
115
+ warn stderr.read
116
+ raise 'bundle install failed' unless exit_status.success?
117
+ end
118
+ end
119
+ end
120
+
121
+ def app_dir(name)
122
+ File.join(working_directory, name)
123
+ end
124
+
125
+ def app_env(name)
126
+ { 'BUNDLE_GEMFILE' => File.join(app_dir(name), 'Gemfile'), 'RAILS_ENV' => 'production' }
127
+ end
128
+
129
+ def working_directory
130
+ $working_directory ||= Dir.mktmpdir('idp_test')
131
+ end
132
+
133
+ def with_clean_env(&blk)
134
+ if Bundler.respond_to?(:with_original_env)
135
+ Bundler.with_original_env(&blk)
136
+ else
137
+ Bundler.with_clean_env(&blk)
138
+ end
139
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SamlRequestMacros
4
+ def make_saml_request(requested_saml_acs_url = 'https://foo.example.com/saml/consume')
5
+ auth_request = ::OneLogin::RubySaml::Authrequest.new
6
+ auth_url = auth_request.create(saml_settings(saml_acs_url: requested_saml_acs_url))
7
+ CGI.unescape(auth_url.split('=').last)
8
+ end
9
+
10
+ def saml_settings(options = {})
11
+ settings = ::OneLogin::RubySaml::Settings.new
12
+ settings.assertion_consumer_service_url = options[:saml_acs_url] || 'https://foo.example.com/saml/consume'
13
+ settings.issuer = options[:issuer] || 'https://foo.example.com/'
14
+ settings.idp_sso_target_url = options[:idp_sso_target_url] || 'http://idp.com/saml/idp'
15
+ settings.idp_cert_fingerprint = StubSamlIdp::Default::FINGERPRINT
16
+ settings.name_identifier_format = StubSamlIdp::Default::NAME_ID_FORMAT
17
+ settings
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Set up a SAML SP
4
+
5
+ gem 'ruby-saml'
6
+ gem 'stub_saml_idp', path: File.expand_path('../..', __dir__)
7
+
8
+ if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('3.1')
9
+ gem 'net-smtp', require: false
10
+ gem 'net-imap', require: false
11
+ gem 'net-pop', require: false
12
+ end
13
+
14
+ route "post '/saml/consume' => 'saml#consume'"
15
+
16
+ file 'app/controllers/saml_controller.rb', <<-CODE
17
+ # frozen_string_literal: true
18
+
19
+ class SamlController < ApplicationController
20
+ skip_before_action :verify_authenticity_token
21
+
22
+ def consume
23
+ response = ::OneLogin::RubySaml::Response.new(params[:SAMLResponse])
24
+ render plain: response.name_id
25
+ end
26
+ end
27
+ CODE
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.push File.expand_path('lib', __dir__)
4
+ require 'stub_saml_idp/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = 'stub_saml_idp'
8
+ s.version = StubSamlIdp::VERSION
9
+ s.platform = Gem::Platform::RUBY
10
+ s.authors = ['Peter M. Goldstein']
11
+ s.email = 'peter.m.goldstein@gmail.com'
12
+ s.homepage = 'http://github.com/petergoldstein/stub_saml_idp'
13
+ s.summary = 'Stub SAML Identity Provider'
14
+ s.description = 'Stub SAML IdP (Identity Provider) library'
15
+ s.license = 'MIT'
16
+
17
+ s.required_ruby_version = '>= 2.5'
18
+ s.metadata['rubygems_mfa_required'] = 'true'
19
+
20
+ s.files = Dir.glob('app/**/*') + Dir.glob('lib/**/*') + [
21
+ 'MIT-LICENSE',
22
+ 'README.md',
23
+ 'Gemfile',
24
+ 'stub_saml_idp.gemspec'
25
+ ]
26
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
27
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
28
+ s.require_paths = ['lib']
29
+ s.rdoc_options = ['--charset=UTF-8']
30
+ s.add_development_dependency('nokogiri')
31
+ s.add_development_dependency('rails', '>= 5.2')
32
+ s.add_development_dependency('rake')
33
+ s.add_development_dependency('rspec', '~> 3.0')
34
+ s.add_development_dependency('ruby-saml')
35
+ s.add_development_dependency('timecop', '~> 0.9.0')
36
+ end
metadata ADDED
@@ -0,0 +1,159 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stub_saml_idp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Peter M. Goldstein
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-01-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '5.2'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '5.2'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ruby-saml
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: timecop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.9.0
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.9.0
97
+ description: Stub SAML IdP (Identity Provider) library
98
+ email: peter.m.goldstein@gmail.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - Gemfile
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - app/controllers/stub_saml_idp/idp_controller.rb
107
+ - app/views/stub_saml_idp/idp/new.html.erb
108
+ - app/views/stub_saml_idp/idp/saml_post.html.erb
109
+ - lib/stub_saml_idp.rb
110
+ - lib/stub_saml_idp/configurator.rb
111
+ - lib/stub_saml_idp/controller.rb
112
+ - lib/stub_saml_idp/default.rb
113
+ - lib/stub_saml_idp/engine.rb
114
+ - lib/stub_saml_idp/version.rb
115
+ - spec/acceptance/acceptance_helper.rb
116
+ - spec/acceptance/idp_controller_spec.rb
117
+ - spec/rails_helper.rb
118
+ - spec/spec_helper.rb
119
+ - spec/stub_saml_idp/controller_spec.rb
120
+ - spec/support/idp_template.rb
121
+ - spec/support/rails_app.rb
122
+ - spec/support/saml_request_macros.rb
123
+ - spec/support/sp_template.rb
124
+ - stub_saml_idp.gemspec
125
+ homepage: http://github.com/petergoldstein/stub_saml_idp
126
+ licenses:
127
+ - MIT
128
+ metadata:
129
+ rubygems_mfa_required: 'true'
130
+ post_install_message:
131
+ rdoc_options:
132
+ - "--charset=UTF-8"
133
+ require_paths:
134
+ - lib
135
+ required_ruby_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '2.5'
140
+ required_rubygems_version: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ requirements: []
146
+ rubygems_version: 3.3.4
147
+ signing_key:
148
+ specification_version: 4
149
+ summary: Stub SAML Identity Provider
150
+ test_files:
151
+ - spec/acceptance/acceptance_helper.rb
152
+ - spec/acceptance/idp_controller_spec.rb
153
+ - spec/rails_helper.rb
154
+ - spec/spec_helper.rb
155
+ - spec/stub_saml_idp/controller_spec.rb
156
+ - spec/support/idp_template.rb
157
+ - spec/support/rails_app.rb
158
+ - spec/support/saml_request_macros.rb
159
+ - spec/support/sp_template.rb