devise_saml_authenticatable 1.3.2 → 1.6.1

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