stub_saml_idp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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