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.
- checksums.yaml +5 -5
- data/.gitignore +0 -2
- data/.travis.yml +29 -22
- data/Gemfile +2 -2
- data/README.md +105 -32
- data/app/controllers/devise/saml_sessions_controller.rb +35 -7
- data/devise_saml_authenticatable.gemspec +2 -1
- data/lib/devise_saml_authenticatable.rb +27 -2
- data/lib/devise_saml_authenticatable/default_attribute_map_resolver.rb +26 -0
- data/lib/devise_saml_authenticatable/default_idp_entity_id_reader.rb +2 -0
- data/lib/devise_saml_authenticatable/exception.rb +1 -1
- data/lib/devise_saml_authenticatable/model.rb +16 -18
- data/lib/devise_saml_authenticatable/routes.rb +17 -6
- data/lib/devise_saml_authenticatable/saml_mapped_attributes.rb +15 -2
- data/lib/devise_saml_authenticatable/strategy.rb +1 -0
- data/lib/devise_saml_authenticatable/version.rb +1 -1
- data/spec/controllers/devise/saml_sessions_controller_spec.rb +118 -11
- data/spec/devise_saml_authenticatable/default_attribute_map_resolver_spec.rb +58 -0
- data/spec/devise_saml_authenticatable/model_spec.rb +68 -7
- data/spec/devise_saml_authenticatable/saml_mapped_attributes_spec.rb +50 -0
- data/spec/features/saml_authentication_spec.rb +45 -21
- data/spec/rails_helper.rb +6 -2
- data/spec/routes/routes_spec.rb +102 -0
- data/spec/spec_helper.rb +7 -0
- data/spec/support/Gemfile.rails4 +23 -6
- data/spec/support/Gemfile.rails5 +13 -2
- data/spec/support/Gemfile.rails5.1 +25 -0
- data/spec/support/Gemfile.rails5.2 +25 -0
- data/spec/support/attribute-map.yml +12 -0
- data/spec/support/attribute_map_resolver.rb.erb +14 -0
- data/spec/support/idp_settings_adapter.rb.erb +5 -5
- data/spec/support/idp_template.rb +6 -2
- data/spec/support/rails_app.rb +75 -17
- data/spec/support/saml_idp_controller.rb.erb +13 -6
- data/spec/support/sp_template.rb +45 -21
- metadata +25 -13
- 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
|
@@ -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
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
-
|
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)
|
@@ -1,31 +1,38 @@
|
|
1
1
|
require 'rails_helper'
|
2
2
|
|
3
|
-
|
4
|
-
|
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
|
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:
|
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:
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
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
|