Skip to content

Commit fc398ff

Browse files
Ability to set attribute_map via config setting (#162)
I added a config hook to set the attribute map dynamically based on the SAML response. By default, it will maintain the existing behavior: look up the `attribute-map.yml` file in the Rails root. Other changes: - The tests started failing on Travis despite working locally—I finally determined that bundler was looking at this repository's Gemfile instead of the test app's Gemfile, so I added a temporary working directory to put the test apps in. That seems to have worked. - Updated the test matrix for new Ruby versions Fixes #65 Co-authored-by: Kevin Trowbridge <[email protected]>
1 parent 2d96f60 commit fc398ff

23 files changed

+363
-112
lines changed

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,4 @@ lib/bundler/man
1313
pkg
1414
rdoc
1515
spec/reports
16-
spec/support/idp/
17-
spec/support/sp/
1816
tmp

.travis.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ rvm:
44
- "2.1.10"
55
- "2.2.10"
66
- "2.3.8"
7-
- "2.4.9"
8-
- "2.5.7"
9-
- "2.6.5"
10-
- "2.7.0"
7+
- "2.4.10"
8+
- "2.5.8"
9+
- "2.6.6"
10+
- "2.7.1"
1111
gemfile:
1212
- Gemfile
1313
- spec/support/Gemfile.rails5.2
@@ -38,11 +38,11 @@ matrix:
3838
gemfile: spec/support/Gemfile.rails5.2
3939
- rvm: "2.3.8"
4040
gemfile: Gemfile
41-
- rvm: "2.4.9"
41+
- rvm: "2.4.10"
4242
gemfile: Gemfile
43-
- rvm: "2.6.3"
43+
- rvm: "2.6.6"
4444
gemfile: spec/support/Gemfile.rails4
45-
- rvm: "2.7.0"
45+
- rvm: "2.7.1"
4646
gemfile: spec/support/Gemfile.rails4
4747

4848
before_install:

README.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,26 @@ In `config/initializers/devise.rb`:
130130
end
131131
```
132132

133-
In the config directory, create a YAML file (`attribute-map.yml`) that maps SAML attributes with your model's fields:
133+
#### Attributes
134+
135+
There are two ways to map SAML attributes to User attributes:
136+
137+
- [initializer](#attribute-map-initializer)
138+
- [config file](#attribute-map-config-file)
139+
140+
The attribute mappings are very dependent on the way the IdP encodes the attributes.
141+
In these examples the attributes are given in URN style.
142+
Other IdPs might provide them as OID's, or by other means.
143+
144+
You are now ready to test it against an IdP.
145+
146+
When the user visits `/users/saml/sign_in` they will be redirected to the login page of the IdP.
147+
148+
Upon successful login the user is redirected to the Devise `user_root_path`.
149+
150+
##### Attribute map config file
151+
152+
Create a YAML file (`config/attribute-map.yml`) that maps SAML attributes with your model's fields:
134153

135154
```yaml
136155
# attribute-map.yml
@@ -141,15 +160,39 @@ In the config directory, create a YAML file (`attribute-map.yml`) that maps SAML
141160
"urn:mace:dir:attribute-def:givenName": "name"
142161
```
143162
144-
The attribute mappings are very dependent on the way the IdP encodes the attributes.
145-
In this example the attributes are given in URN style.
146-
Other IdPs might provide them as OID's, or by other means.
163+
##### Attribute map initializer
147164
148-
You are now ready to test it against an IdP.
165+
In `config/initializers/devise.rb` (see above), add an attribute map resolver.
166+
The resolver gets the [SAML response from the IdP](https://github.com/onelogin/ruby-saml/blob/master/lib/onelogin/ruby-saml/response.rb) so it can decide which attribute map to load.
167+
If you only have one IdP, you can use the config file above, or just return a single hash.
149168

150-
When the user visits `/users/saml/sign_in` they will be redirected to the login page of the IdP.
169+
```ruby
170+
# config/initializers/devise.rb
171+
Devise.setup do |config|
172+
...
173+
# ==> Configuration for :saml_authenticatable
151174
152-
Upon successful login the user is redirected to the Devise `user_root_path`.
175+
config.saml_attribute_map_resolver = MyAttributeMapResolver
176+
end
177+
```
178+
179+
```ruby
180+
# app/lib/my_attribute_map_resolver
181+
class MyAttributeMapResolver < DeviseSamlAuthenticatable::DefaultAttributeMapResolver
182+
def attribute_map
183+
issuer = saml_response.issuers.first
184+
case issuer
185+
when "idp_entity_id"
186+
{
187+
"urn:mace:dir:attribute-def:uid" => "user_name",
188+
"urn:mace:dir:attribute-def:email" => "email",
189+
"urn:mace:dir:attribute-def:name" => "last_name",
190+
"urn:mace:dir:attribute-def:givenName" => "name",
191+
}
192+
end
193+
end
194+
end
195+
```
153196

154197
## Supporting Multiple IdPs
155198

app/controllers/devise/saml_sessions_controller.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ def after_sign_out_path_for(_)
7979
end
8080

8181
def generate_idp_logout_response(saml_config, logout_request_id)
82-
OneLogin::RubySaml::SloLogoutresponse.new.create(saml_config, logout_request_id, nil)
82+
83+
params = {}
84+
if relay_state
85+
params[:RelayState] = relay_state
86+
end
87+
88+
OneLogin::RubySaml::SloLogoutresponse.new.create(saml_config, logout_request_id, nil, params)
8389
end
8490
end

lib/devise_saml_authenticatable.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "devise_saml_authenticatable/logger"
66
require "devise_saml_authenticatable/routes"
77
require "devise_saml_authenticatable/saml_config"
8+
require "devise_saml_authenticatable/default_attribute_map_resolver"
89
require "devise_saml_authenticatable/default_idp_entity_id_reader"
910

1011
begin
@@ -66,6 +67,10 @@ module Devise
6667
mattr_accessor :saml_relay_state
6768
@@saml_relay_state
6869

70+
# Instead of storing the attribute_map in attribute-map.yml, store it in the database, or set it programatically
71+
mattr_accessor :saml_attribute_map_resolver
72+
@@saml_attribute_map_resolver ||= ::DeviseSamlAuthenticatable::DefaultAttributeMapResolver
73+
6974
# Implements a #validate method that takes the retrieved resource and response right after retrieval,
7075
# and returns true if it's valid. False will cause authentication to fail.
7176
# Only one of saml_resource_validator and saml_resource_validator_hook may be used.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/devise_saml_authenticatable/model.rb

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ def authenticate_with_saml(saml_response, relay_state)
3333
key = Devise.saml_default_user_key
3434
decorated_response = ::SamlAuthenticatable::SamlResponse.new(
3535
saml_response,
36-
attribute_map
36+
Devise.saml_attribute_map_resolver.new(saml_response).attribute_map,
3737
)
38-
if (Devise.saml_use_subject)
38+
if Devise.saml_use_subject
3939
auth_value = saml_response.name_id
4040
else
4141
auth_value = decorated_response.attribute_value_by_resource_key(key)
@@ -80,21 +80,6 @@ def reset_session_key_for(name_id)
8080
def find_for_shibb_authentication(conditions)
8181
find_for_authentication(conditions)
8282
end
83-
84-
def attribute_map
85-
@attribute_map ||= attribute_map_for_environment
86-
end
87-
88-
private
89-
90-
def attribute_map_for_environment
91-
attribute_map = YAML.load(File.read("#{Rails.root}/config/attribute-map.yml"))
92-
if attribute_map.has_key?(Rails.env)
93-
attribute_map[Rails.env]
94-
else
95-
attribute_map
96-
end
97-
end
9883
end
9984
end
10085
end

lib/devise_saml_authenticatable/strategy.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def valid?
88
if params[:SAMLResponse]
99
OneLogin::RubySaml::Response.new(
1010
params[:SAMLResponse],
11-
settings: Devise.saml_config,
11+
settings: saml_config(get_idp_entity_id(params)),
1212
allowed_clock_drift: Devise.allowed_clock_drift_in_seconds,
1313
)
1414
else

spec/controllers/devise/saml_sessions_controller_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,16 @@ def self.entity_id(params)
333333
end
334334
end
335335

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+
336346
context 'when saml_session_index_key is not configured' do
337347
before do
338348
Devise.saml_session_index_key = nil
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)