devise_saml_authenticatable 1.4.0 → 1.6.2

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