devise_saml_authenticatable 1.3.2 → 1.6.1

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.
Files changed (37) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +0 -2
  3. data/.travis.yml +29 -22
  4. data/Gemfile +2 -2
  5. data/README.md +105 -32
  6. data/app/controllers/devise/saml_sessions_controller.rb +35 -7
  7. data/devise_saml_authenticatable.gemspec +2 -1
  8. data/lib/devise_saml_authenticatable.rb +27 -2
  9. data/lib/devise_saml_authenticatable/default_attribute_map_resolver.rb +26 -0
  10. data/lib/devise_saml_authenticatable/default_idp_entity_id_reader.rb +2 -0
  11. data/lib/devise_saml_authenticatable/exception.rb +1 -1
  12. data/lib/devise_saml_authenticatable/model.rb +16 -18
  13. data/lib/devise_saml_authenticatable/routes.rb +17 -6
  14. data/lib/devise_saml_authenticatable/saml_mapped_attributes.rb +15 -2
  15. data/lib/devise_saml_authenticatable/strategy.rb +1 -0
  16. data/lib/devise_saml_authenticatable/version.rb +1 -1
  17. data/spec/controllers/devise/saml_sessions_controller_spec.rb +118 -11
  18. data/spec/devise_saml_authenticatable/default_attribute_map_resolver_spec.rb +58 -0
  19. data/spec/devise_saml_authenticatable/model_spec.rb +68 -7
  20. data/spec/devise_saml_authenticatable/saml_mapped_attributes_spec.rb +50 -0
  21. data/spec/features/saml_authentication_spec.rb +45 -21
  22. data/spec/rails_helper.rb +6 -2
  23. data/spec/routes/routes_spec.rb +102 -0
  24. data/spec/spec_helper.rb +7 -0
  25. data/spec/support/Gemfile.rails4 +23 -6
  26. data/spec/support/Gemfile.rails5 +13 -2
  27. data/spec/support/Gemfile.rails5.1 +25 -0
  28. data/spec/support/Gemfile.rails5.2 +25 -0
  29. data/spec/support/attribute-map.yml +12 -0
  30. data/spec/support/attribute_map_resolver.rb.erb +14 -0
  31. data/spec/support/idp_settings_adapter.rb.erb +5 -5
  32. data/spec/support/idp_template.rb +6 -2
  33. data/spec/support/rails_app.rb +75 -17
  34. data/spec/support/saml_idp_controller.rb.erb +13 -6
  35. data/spec/support/sp_template.rb +45 -21
  36. metadata +25 -13
  37. data/spec/support/Gemfile.ruby-saml-1.3 +0 -24
@@ -0,0 +1,26 @@
1
+ module DeviseSamlAuthenticatable
2
+ class DefaultAttributeMapResolver
3
+ def initialize(saml_response)
4
+ @saml_response = saml_response
5
+ end
6
+
7
+ def attribute_map
8
+ return {} unless File.exist?(attribute_map_path)
9
+
10
+ attribute_map = YAML.load(File.read(attribute_map_path))
11
+ if attribute_map.key?(Rails.env)
12
+ attribute_map[Rails.env]
13
+ else
14
+ attribute_map
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :saml_response
21
+
22
+ def attribute_map_path
23
+ Rails.root.join("config", "attribute-map.yml")
24
+ end
25
+ end
26
+ end
@@ -4,11 +4,13 @@ module DeviseSamlAuthenticatable
4
4
  if params[:SAMLRequest]
5
5
  OneLogin::RubySaml::SloLogoutrequest.new(
6
6
  params[:SAMLRequest],
7
+ settings: Devise.saml_config,
7
8
  allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
8
9
  ).issuer
9
10
  elsif params[:SAMLResponse]
10
11
  OneLogin::RubySaml::Response.new(
11
12
  params[:SAMLResponse],
13
+ settings: Devise.saml_config,
12
14
  allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
13
15
  ).issuers.first
14
16
  end
@@ -1,6 +1,6 @@
1
1
  module DeviseSamlAuthenticatable
2
2
 
3
- class SamlException < Exception
3
+ class SamlException < StandardError
4
4
  end
5
5
 
6
6
  end
@@ -33,9 +33,9 @@ module Devise
33
33
  key = Devise.saml_default_user_key
34
34
  decorated_response = ::SamlAuthenticatable::SamlResponse.new(
35
35
  saml_response,
36
- attribute_map
36
+ attribute_map(saml_response),
37
37
  )
38
- if (Devise.saml_use_subject)
38
+ if Devise.saml_use_subject
39
39
  auth_value = saml_response.name_id
40
40
  else
41
41
  auth_value = decorated_response.attribute_value_by_resource_key(key)
@@ -44,8 +44,12 @@ module Devise
44
44
 
45
45
  resource = Devise.saml_resource_locator.call(self, decorated_response, auth_value)
46
46
 
47
- if Devise.saml_resource_validator
48
- if not Devise.saml_resource_validator.new.validate(resource, saml_response)
47
+ raise "Only one validator configuration can be used at a time" if Devise.saml_resource_validator && Devise.saml_resource_validator_hook
48
+ if Devise.saml_resource_validator || Devise.saml_resource_validator_hook
49
+ valid = if Devise.saml_resource_validator then Devise.saml_resource_validator.new.validate(resource, saml_response)
50
+ else Devise.saml_resource_validator_hook.call(resource, decorated_response, auth_value)
51
+ end
52
+ if !valid
49
53
  logger.info("User(#{auth_value}) did not pass custom validation.")
50
54
  return nil
51
55
  end
@@ -62,7 +66,12 @@ module Devise
62
66
  end
63
67
 
64
68
  if Devise.saml_update_user || (resource.new_record? && Devise.saml_create_user)
65
- Devise.saml_update_resource_hook.call(resource, decorated_response, auth_value)
69
+ begin
70
+ Devise.saml_update_resource_hook.call(resource, decorated_response, auth_value)
71
+ rescue
72
+ logger.info("User(#{auth_value}) failed to create or update.")
73
+ return nil
74
+ end
66
75
  end
67
76
 
68
77
  resource
@@ -77,19 +86,8 @@ module Devise
77
86
  find_for_authentication(conditions)
78
87
  end
79
88
 
80
- def attribute_map
81
- @attribute_map ||= attribute_map_for_environment
82
- end
83
-
84
- private
85
-
86
- def attribute_map_for_environment
87
- attribute_map = YAML.load(File.read("#{Rails.root}/config/attribute-map.yml"))
88
- if attribute_map.has_key?(Rails.env)
89
- attribute_map[Rails.env]
90
- else
91
- attribute_map
92
- end
89
+ def attribute_map(saml_response = nil)
90
+ Devise.saml_attribute_map_resolver.new(saml_response).attribute_map
93
91
  end
94
92
  end
95
93
  end
@@ -1,12 +1,23 @@
1
1
  ActionDispatch::Routing::Mapper.class_eval do
2
2
  protected
3
3
  def devise_saml_authenticatable(mapping, controllers)
4
- resource :session, :only => [], :controller => controllers[:saml_sessions], :path => "" do
5
- get :new, :path => "saml/sign_in", :as => "new"
6
- post :create, :path=>"saml/auth"
7
- match :destroy, :path => mapping.path_names[:sign_out], :as => "destroy", :via => mapping.sign_out_via
8
- get :metadata, :path=>"saml/metadata"
9
- match :idp_sign_out, :path=>"saml/idp_sign_out", via: [:get, :post]
4
+ if ::Devise.saml_route_helper_prefix
5
+ prefix = ::Devise.saml_route_helper_prefix
6
+ resource :session, only: [], controller: controllers[:saml_sessions], path: '' do
7
+ get :new, path: 'saml/sign_in', as: "new_#{prefix}"
8
+ post :create, path: 'saml/auth', as: prefix
9
+ match :destroy, path: mapping.path_names[:sign_out], as: "destroy_#{prefix}", via: mapping.sign_out_via
10
+ get :metadata, path: 'saml/metadata'
11
+ match :idp_sign_out, path: 'saml/idp_sign_out', as: "idp_destroy_#{prefix}", via: [:get, :post]
12
+ end
13
+ else
14
+ resource :session, only: [], controller: controllers[:saml_sessions], path: '' do
15
+ get :new, path: 'saml/sign_in', as: 'new'
16
+ post :create, path: 'saml/auth'
17
+ match :destroy, path: mapping.path_names[:sign_out], as: 'destroy', via: mapping.sign_out_via
18
+ get :metadata, path: 'saml/metadata'
19
+ match :idp_sign_out, path: 'saml/idp_sign_out', via: [:get, :post]
20
+ end
10
21
  end
11
22
  end
12
23
  end
@@ -3,7 +3,6 @@ module SamlAuthenticatable
3
3
  def initialize(attributes, attribute_map)
4
4
  @attributes = attributes
5
5
  @attribute_map = attribute_map
6
- @inverted_attribute_map = @attribute_map.invert
7
6
  end
8
7
 
9
8
  def saml_attribute_keys
@@ -15,7 +14,21 @@ module SamlAuthenticatable
15
14
  end
16
15
 
17
16
  def value_by_resource_key(key)
18
- value_by_saml_attribute_key(@inverted_attribute_map.fetch(String(key)))
17
+ str_key = String(key)
18
+
19
+ # Find all of the SAML attributes that map to the resource key
20
+ attribute_map_for_key = @attribute_map.select { |_, resource_key| String(resource_key) == str_key }
21
+
22
+ saml_value = nil
23
+
24
+ # Find the first non-nil value
25
+ attribute_map_for_key.each_key do |saml_key|
26
+ saml_value = value_by_saml_attribute_key(saml_key)
27
+
28
+ break unless saml_value.nil?
29
+ end
30
+
31
+ saml_value
19
32
  end
20
33
 
21
34
  def value_by_saml_attribute_key(key)
@@ -8,6 +8,7 @@ module Devise
8
8
  if params[:SAMLResponse]
9
9
  OneLogin::RubySaml::Response.new(
10
10
  params[:SAMLResponse],
11
+ settings: saml_config(get_idp_entity_id(params)),
11
12
  allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
12
13
  )
13
14
  else
@@ -1,3 +1,3 @@
1
1
  module DeviseSamlAuthenticatable
2
- VERSION = "1.3.2"
2
+ VERSION = "1.6.1"
3
3
  end
@@ -1,31 +1,38 @@
1
1
  require 'rails_helper'
2
2
 
3
- class Devise::SessionsController < ActionController::Base
4
- # The important parts from devise
3
+ # The important parts from devise
4
+ class DeviseController < ApplicationController
5
+ attr_accessor :current_user
6
+
5
7
  def resource_class
6
8
  User
7
9
  end
8
10
 
11
+ def require_no_authentication
12
+ end
13
+ end
14
+ class Devise::SessionsController < DeviseController
9
15
  def destroy
10
16
  sign_out
11
17
  redirect_to after_sign_out_path_for(:user)
12
18
  end
13
19
 
14
- def require_no_authentication
20
+ def verify_signed_out_user
21
+ # no-op for these tests
15
22
  end
16
23
  end
17
24
 
18
25
  require_relative '../../../app/controllers/devise/saml_sessions_controller'
19
26
 
20
27
  describe Devise::SamlSessionsController, type: :controller do
21
- let(:saml_config) { Devise.saml_config }
22
28
  let(:idp_providers_adapter) { spy("Stub IDPSettings Adaptor") }
23
29
 
24
30
  before do
31
+ @request.env["devise.mapping"] = Devise.mappings[:user]
25
32
  allow(idp_providers_adapter).to receive(:settings).and_return({
26
33
  assertion_consumer_service_url: "acs_url",
27
34
  assertion_consumer_service_binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
28
- name_identifier_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
35
+ name_identifier_format: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
29
36
  issuer: "sp_issuer",
30
37
  idp_entity_id: "http://www.example.com",
31
38
  authn_context: "",
@@ -35,6 +42,20 @@ describe Devise::SamlSessionsController, type: :controller do
35
42
  })
36
43
  end
37
44
 
45
+ before do
46
+ if Rails::VERSION::MAJOR < 5 && Gem::Version.new(RUBY_VERSION) > Gem::Version.new("2.6")
47
+ # we still want to support Rails 4
48
+ # patch tests using snippet from https://github.com/rails/rails/issues/34790#issuecomment-483607370
49
+ class ActionController::TestResponse < ActionDispatch::TestResponse
50
+ def recycle!
51
+ @mon_mutex_owner_object_id = nil
52
+ @mon_mutex = nil
53
+ initialize
54
+ end
55
+ end
56
+ end
57
+ end
58
+
38
59
  describe '#new' do
39
60
  let(:saml_response) { File.read(File.join(File.dirname(__FILE__), '../../support', 'response_encrypted_nameid.xml.base64')) }
40
61
 
@@ -66,6 +87,7 @@ describe Devise::SamlSessionsController, type: :controller do
66
87
  it "uses the DefaultIdpEntityIdReader" do
67
88
  expect(DeviseSamlAuthenticatable::DefaultIdpEntityIdReader).to receive(:entity_id)
68
89
  do_get
90
+ expect(idp_providers_adapter).to have_received(:settings).with(nil)
69
91
  end
70
92
 
71
93
  context "with a relay_state lambda defined" do
@@ -104,6 +126,7 @@ describe Devise::SamlSessionsController, type: :controller do
104
126
 
105
127
  it "redirects to the associated IdP SSO target url" do
106
128
  do_get
129
+ expect(idp_providers_adapter).to have_received(:settings).with("http://www.example.com")
107
130
  expect(response).to redirect_to(%r(\Ahttp://idp_sso_url\?SAMLRequest=))
108
131
  end
109
132
  end
@@ -111,6 +134,8 @@ describe Devise::SamlSessionsController, type: :controller do
111
134
  end
112
135
 
113
136
  describe '#metadata' do
137
+ let(:saml_config) { Devise.saml_config.dup }
138
+
114
139
  context "with the default configuration" do
115
140
  it 'generates metadata' do
116
141
  get :metadata
@@ -130,7 +155,7 @@ describe Devise::SamlSessionsController, type: :controller do
130
155
  Devise.saml_configure do |settings|
131
156
  settings.assertion_consumer_service_url = "http://localhost:3000/users/saml/auth"
132
157
  settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
133
- settings.name_identifier_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
158
+ settings.name_identifier_format = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
134
159
  settings.issuer = "http://localhost:3000"
135
160
  end
136
161
  end
@@ -147,10 +172,81 @@ describe Devise::SamlSessionsController, type: :controller do
147
172
  end
148
173
 
149
174
  describe '#destroy' do
150
- it 'signs out and redirects to the IdP' do
151
- expect(controller).to receive(:sign_out)
152
- delete :destroy
153
- expect(response).to redirect_to(%r(\Ahttp://localhost:8009/saml/logout\?SAMLRequest=))
175
+ before do
176
+ allow(controller).to receive(:sign_out)
177
+ end
178
+
179
+ context "when using the default saml config" do
180
+ it "signs out and redirects to the IdP" do
181
+ delete :destroy
182
+ expect(controller).to have_received(:sign_out)
183
+ expect(response).to redirect_to(%r(\Ahttp://localhost:8009/saml/logout\?SAMLRequest=))
184
+ end
185
+ end
186
+
187
+ context "when configured to use a non-transient name identifier" do
188
+ before do
189
+ allow(Devise.saml_config).to receive(:name_identifier_format).and_return("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent")
190
+ end
191
+
192
+ it "includes a LogoutRequest with the name identifier and session index", :aggregate_failures do
193
+ controller.current_user = Struct.new(:email, :session_index).new("user@example.com", "sessionindex")
194
+
195
+ actual_settings = nil
196
+ expect_any_instance_of(OneLogin::RubySaml::Logoutrequest).to receive(:create) do |_, settings|
197
+ actual_settings = settings
198
+ "http://localhost:8009/saml/logout"
199
+ end
200
+
201
+ delete :destroy
202
+ expect(actual_settings.name_identifier_value).to eq("user@example.com")
203
+ expect(actual_settings.sessionindex).to eq("sessionindex")
204
+ end
205
+ end
206
+
207
+ context "with a specified idp" do
208
+ before do
209
+ Devise.idp_settings_adapter = idp_providers_adapter
210
+ end
211
+
212
+ it "redirects to the associated IdP SSO target url" do
213
+ expect(DeviseSamlAuthenticatable::DefaultIdpEntityIdReader).to receive(:entity_id)
214
+ delete :destroy
215
+ expect(controller).to have_received(:sign_out)
216
+ expect(response).to redirect_to(%r(\Ahttp://idp_slo_url\?SAMLRequest=))
217
+ end
218
+
219
+ context "with a specified idp entity id reader" do
220
+ class OurIdpEntityIdReader
221
+ def self.entity_id(params)
222
+ params[:entity_id]
223
+ end
224
+ end
225
+
226
+ subject(:do_delete) {
227
+ if Rails::VERSION::MAJOR > 4
228
+ delete :destroy, params: {entity_id: "http://www.example.com"}
229
+ else
230
+ delete :destroy, entity_id: "http://www.example.com"
231
+ end
232
+ }
233
+
234
+ before do
235
+ @default_reader = Devise.idp_entity_id_reader
236
+ Devise.idp_entity_id_reader = OurIdpEntityIdReader # which will have some different behavior
237
+ end
238
+
239
+ after do
240
+ Devise.idp_entity_id_reader = @default_reader
241
+ end
242
+
243
+ it "redirects to the associated IdP SLO target url" do
244
+ do_delete
245
+ expect(controller).to have_received(:sign_out)
246
+ expect(idp_providers_adapter).to have_received(:settings).with("http://www.example.com")
247
+ expect(response).to redirect_to(%r(\Ahttp://idp_slo_url\?SAMLRequest=))
248
+ end
249
+ end
154
250
  end
155
251
  end
156
252
 
@@ -214,12 +310,13 @@ describe Devise::SamlSessionsController, type: :controller do
214
310
  let(:name_id) { '12312312' }
215
311
  before do
216
312
  allow(OneLogin::RubySaml::SloLogoutrequest).to receive(:new).and_return(saml_request)
313
+ allow(User).to receive(:reset_session_key_for)
217
314
  end
218
315
 
219
316
  it 'direct the resource to reset the session key' do
220
- expect(User).to receive(:reset_session_key_for).with(name_id)
221
317
  do_post
222
318
  expect(response).to redirect_to response_url
319
+ expect(User).to have_received(:reset_session_key_for).with(name_id)
223
320
  end
224
321
 
225
322
  context "with a specified idp" do
@@ -236,6 +333,16 @@ describe Devise::SamlSessionsController, type: :controller do
236
333
  end
237
334
  end
238
335
 
336
+ context "with a relay_state lambda defined" do
337
+ let(:relay_state) { ->(request) { "123" } }
338
+
339
+ it "includes the RelayState param in the request to the IdP" do
340
+ expect(Devise).to receive(:saml_relay_state).at_least(:once).and_return(relay_state)
341
+ do_post
342
+ expect(saml_response).to have_received(:create).with(Devise.saml_config, saml_request.id, nil, {RelayState: "123"})
343
+ end
344
+ end
345
+
239
346
  context 'when saml_session_index_key is not configured' do
240
347
  before do
241
348
  Devise.saml_session_index_key = nil
@@ -0,0 +1,58 @@
1
+ require "spec_helper"
2
+ require "devise_saml_authenticatable/default_attribute_map_resolver"
3
+
4
+ describe DeviseSamlAuthenticatable::DefaultAttributeMapResolver do
5
+ let!(:rails) { class_double("Rails", env: "test", logger: logger, root: rails_root).as_stubbed_const }
6
+ let(:logger) { instance_double("Logger", info: nil) }
7
+ let(:rails_root) { Pathname.new("tmp") }
8
+
9
+ let(:saml_response) { instance_double("OneLogin::RubySaml::Response") }
10
+ let(:file_contents) {
11
+ <<YAML
12
+ ---
13
+ firstname: first_name
14
+ lastname: last_name
15
+ YAML
16
+ }
17
+ before do
18
+ allow(File).to receive(:exist?).and_return(true)
19
+ allow(File).to receive(:read).and_return(file_contents)
20
+ end
21
+
22
+ describe "#attribute_map" do
23
+ it "reads the attribute map from the config file" do
24
+ expect(described_class.new(saml_response).attribute_map).to eq(
25
+ "firstname" => "first_name",
26
+ "lastname" => "last_name",
27
+ )
28
+ expect(File).to have_received(:read).with(Pathname.new("tmp").join("config", "attribute-map.yml"))
29
+ end
30
+
31
+ context "when the attribute map is broken down by environment" do
32
+ let(:file_contents) {
33
+ <<YAML
34
+ ---
35
+ test:
36
+ first: first_name
37
+ last: last_name
38
+ YAML
39
+ }
40
+ it "reads the attribute map from the environment key" do
41
+ expect(described_class.new(saml_response).attribute_map).to eq(
42
+ "first" => "first_name",
43
+ "last" => "last_name",
44
+ )
45
+ end
46
+ end
47
+
48
+ context "when the config file does not exist" do
49
+ before do
50
+ allow(File).to receive(:exist?).and_return(false)
51
+ end
52
+
53
+ it "is an empty hash" do
54
+ expect(described_class.new(saml_response).attribute_map).to eq({})
55
+ end
56
+ end
57
+ end
58
+ end