devise_saml_authenticatable 1.4.0 → 1.6.2

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 (36) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +0 -2
  3. data/.travis.yml +27 -24
  4. data/Gemfile +2 -2
  5. data/README.md +99 -30
  6. data/app/controllers/devise/saml_sessions_controller.rb +34 -7
  7. data/devise_saml_authenticatable.gemspec +1 -1
  8. data/lib/devise_saml_authenticatable.rb +25 -0
  9. data/lib/devise_saml_authenticatable/default_attribute_map_resolver.rb +26 -0
  10. data/lib/devise_saml_authenticatable/exception.rb +1 -1
  11. data/lib/devise_saml_authenticatable/model.rb +10 -17
  12. data/lib/devise_saml_authenticatable/routes.rb +17 -6
  13. data/lib/devise_saml_authenticatable/saml_mapped_attributes.rb +15 -2
  14. data/lib/devise_saml_authenticatable/strategy.rb +1 -1
  15. data/lib/devise_saml_authenticatable/version.rb +1 -1
  16. data/spec/controllers/devise/saml_sessions_controller_spec.rb +69 -11
  17. data/spec/devise_saml_authenticatable/default_attribute_map_resolver_spec.rb +58 -0
  18. data/spec/devise_saml_authenticatable/model_spec.rb +55 -7
  19. data/spec/devise_saml_authenticatable/saml_mapped_attributes_spec.rb +50 -0
  20. data/spec/features/saml_authentication_spec.rb +45 -37
  21. data/spec/rails_helper.rb +6 -2
  22. data/spec/routes/routes_spec.rb +102 -0
  23. data/spec/spec_helper.rb +7 -0
  24. data/spec/support/Gemfile.rails4 +20 -10
  25. data/spec/support/Gemfile.rails5 +13 -2
  26. data/spec/support/Gemfile.rails5.1 +25 -0
  27. data/spec/support/Gemfile.rails5.2 +25 -0
  28. data/spec/support/attribute-map.yml +12 -0
  29. data/spec/support/attribute_map_resolver.rb.erb +14 -0
  30. data/spec/support/idp_settings_adapter.rb.erb +5 -5
  31. data/spec/support/idp_template.rb +6 -2
  32. data/spec/support/rails_app.rb +75 -17
  33. data/spec/support/saml_idp_controller.rb.erb +13 -6
  34. data/spec/support/sp_template.rb +45 -21
  35. metadata +23 -12
  36. data/spec/support/Gemfile.ruby-saml-1.3 +0 -22
@@ -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
@@ -77,19 +81,8 @@ module Devise
77
81
  find_for_authentication(conditions)
78
82
  end
79
83
 
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
84
+ def attribute_map(saml_response = nil)
85
+ Devise.saml_attribute_map_resolver.new(saml_response).attribute_map
93
86
  end
94
87
  end
95
88
  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,7 +8,7 @@ module Devise
8
8
  if params[:SAMLResponse]
9
9
  OneLogin::RubySaml::Response.new(
10
10
  params[:SAMLResponse],
11
- settings: Devise.saml_config,
11
+ settings: saml_config(get_idp_entity_id(params)),
12
12
  allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
13
13
  )
14
14
  else
@@ -1,3 +1,3 @@
1
1
  module DeviseSamlAuthenticatable
2
- VERSION = "1.4.0"
2
+ VERSION = "1.6.2"
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
 
@@ -113,6 +134,8 @@ describe Devise::SamlSessionsController, type: :controller do
113
134
  end
114
135
 
115
136
  describe '#metadata' do
137
+ let(:saml_config) { Devise.saml_config.dup }
138
+
116
139
  context "with the default configuration" do
117
140
  it 'generates metadata' do
118
141
  get :metadata
@@ -132,7 +155,7 @@ describe Devise::SamlSessionsController, type: :controller do
132
155
  Devise.saml_configure do |settings|
133
156
  settings.assertion_consumer_service_url = "http://localhost:3000/users/saml/auth"
134
157
  settings.assertion_consumer_service_binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
135
- 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"
136
159
  settings.issuer = "http://localhost:3000"
137
160
  end
138
161
  end
@@ -149,23 +172,47 @@ describe Devise::SamlSessionsController, type: :controller do
149
172
  end
150
173
 
151
174
  describe '#destroy' do
175
+ before do
176
+ allow(controller).to receive(:sign_out)
177
+ end
178
+
152
179
  context "when using the default saml config" do
153
- it 'signs out and redirects to the IdP' do
154
- expect(controller).to receive(:sign_out)
180
+ it "signs out and redirects to the IdP" do
155
181
  delete :destroy
182
+ expect(controller).to have_received(:sign_out)
156
183
  expect(response).to redirect_to(%r(\Ahttp://localhost:8009/saml/logout\?SAMLRequest=))
157
184
  end
158
185
  end
159
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
+
160
207
  context "with a specified idp" do
161
208
  before do
162
209
  Devise.idp_settings_adapter = idp_providers_adapter
163
210
  end
164
211
 
165
212
  it "redirects to the associated IdP SSO target url" do
166
- expect(controller).to receive(:sign_out)
167
213
  expect(DeviseSamlAuthenticatable::DefaultIdpEntityIdReader).to receive(:entity_id)
168
214
  delete :destroy
215
+ expect(controller).to have_received(:sign_out)
169
216
  expect(response).to redirect_to(%r(\Ahttp://idp_slo_url\?SAMLRequest=))
170
217
  end
171
218
 
@@ -194,8 +241,8 @@ describe Devise::SamlSessionsController, type: :controller do
194
241
  end
195
242
 
196
243
  it "redirects to the associated IdP SLO target url" do
197
- expect(controller).to receive(:sign_out)
198
244
  do_delete
245
+ expect(controller).to have_received(:sign_out)
199
246
  expect(idp_providers_adapter).to have_received(:settings).with("http://www.example.com")
200
247
  expect(response).to redirect_to(%r(\Ahttp://idp_slo_url\?SAMLRequest=))
201
248
  end
@@ -263,12 +310,13 @@ describe Devise::SamlSessionsController, type: :controller do
263
310
  let(:name_id) { '12312312' }
264
311
  before do
265
312
  allow(OneLogin::RubySaml::SloLogoutrequest).to receive(:new).and_return(saml_request)
313
+ allow(User).to receive(:reset_session_key_for)
266
314
  end
267
315
 
268
316
  it 'direct the resource to reset the session key' do
269
- expect(User).to receive(:reset_session_key_for).with(name_id)
270
317
  do_post
271
318
  expect(response).to redirect_to response_url
319
+ expect(User).to have_received(:reset_session_key_for).with(name_id)
272
320
  end
273
321
 
274
322
  context "with a specified idp" do
@@ -285,6 +333,16 @@ describe Devise::SamlSessionsController, type: :controller do
285
333
  end
286
334
  end
287
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
+
288
346
  context 'when saml_session_index_key is not configured' do
289
347
  before do
290
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
@@ -32,6 +32,7 @@ describe Devise::Models::SamlAuthenticatable do
32
32
  end
33
33
 
34
34
  before do
35
+ allow(Devise).to receive(:saml_attribute_map_resolver).and_return(attribute_map_resolver)
35
36
  allow(Devise).to receive(:saml_default_user_key).and_return(:email)
36
37
  allow(Devise).to receive(:saml_create_user).and_return(false)
37
38
  allow(Devise).to receive(:saml_use_subject).and_return(false)
@@ -39,13 +40,19 @@ describe Devise::Models::SamlAuthenticatable do
39
40
 
40
41
  before do
41
42
  allow(Rails).to receive(:root).and_return("/railsroot")
42
- allow(File).to receive(:read).with("/railsroot/config/attribute-map.yml").and_return(<<-ATTRIBUTEMAP)
43
- ---
44
- "saml-email-format": email
45
- "saml-name-format": name
46
- ATTRIBUTEMAP
47
43
  end
48
44
 
45
+ let(:attribute_map_resolver) {
46
+ Class.new(::DeviseSamlAuthenticatable::DefaultAttributeMapResolver) do
47
+ def attribute_map
48
+ {
49
+ "saml-email-format" => "email",
50
+ "saml-name-format" => "name",
51
+ }
52
+ end
53
+ end
54
+ }
55
+ let(:attributemap) { attribute_map_resolver.new(nil).attribute_map }
49
56
  let(:response) { double(:response, attributes: attributes, name_id: name_id) }
50
57
  let(:attributes) {
51
58
  OneLogin::RubySaml::Attributes.new(
@@ -179,8 +186,8 @@ describe Devise::Models::SamlAuthenticatable do
179
186
  end
180
187
  end
181
188
 
182
- context "when configured with a resource validator" do
183
- let(:validator_class) { double("validator_class") }
189
+ context "when configured with a resource validator class" do
190
+ let(:validator_class) { double("validator") }
184
191
  let(:validator) { double("validator") }
185
192
  let(:user) { Model.new(new_record: false) }
186
193
 
@@ -212,6 +219,41 @@ describe Devise::Models::SamlAuthenticatable do
212
219
  end
213
220
  end
214
221
 
222
+
223
+ context "when configured with a resource validator hook" do
224
+ let(:validator_hook) { double("validator_hook") }
225
+ let(:decorated_response) { ::SamlAuthenticatable::SamlResponse.new(response, attributemap) }
226
+ let(:user) { Model.new(new_record: false) }
227
+
228
+ before do
229
+ allow(Devise).to receive(:saml_resource_validator_hook).and_return(validator_hook)
230
+ allow(::SamlAuthenticatable::SamlResponse).to receive(:new).with(response, attributemap).and_return(decorated_response)
231
+ end
232
+
233
+ context "and sent a valid value" do
234
+ before do
235
+ expect(validator_hook).to receive(:call).with(user, decorated_response, 'user@example.com').and_return(true)
236
+ end
237
+
238
+ it "returns the user" do
239
+ expect(Model).to receive(:where).with(email: 'user@example.com').and_return([user])
240
+ expect(Model.authenticate_with_saml(response, nil)).to eq(user)
241
+ end
242
+ end
243
+
244
+ context "and sent an invalid value" do
245
+ before do
246
+ expect(validator_hook).to receive(:call).with(user, decorated_response, 'user@example.com').and_return(false)
247
+ end
248
+
249
+ it "returns nil" do
250
+ expect(Model).to receive(:where).with(email: 'user@example.com').and_return([user])
251
+ expect(Model.authenticate_with_saml(response, nil)).to be_nil
252
+ end
253
+ end
254
+ end
255
+
256
+
215
257
  context "when configured to use a custom update hook" do
216
258
  it "can replicate the default behaviour in a custom hook" do
217
259
  configure_hook do |user, saml_response|
@@ -330,4 +372,10 @@ describe Devise::Models::SamlAuthenticatable do
330
372
  allow(Devise).to receive(:saml_resource_locator).and_return(block)
331
373
  end
332
374
  end
375
+
376
+ describe "::attribute_map" do
377
+ it "returns the attribute map" do
378
+ expect(Model.attribute_map).to eq(attributemap)
379
+ end
380
+ end
333
381
  end