maestrano-ruby-test 0.8.3
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 +7 -0
- data/.gitignore +34 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +45 -0
- data/LICENSE +21 -0
- data/README.md +794 -0
- data/Rakefile +40 -0
- data/bin/maestrano-console +9 -0
- data/lib/maestrano.rb +271 -0
- data/lib/maestrano/account/bill.rb +14 -0
- data/lib/maestrano/account/recurring_bill.rb +14 -0
- data/lib/maestrano/api/error/authentication_error.rb +8 -0
- data/lib/maestrano/api/error/base_error.rb +24 -0
- data/lib/maestrano/api/error/connection_error.rb +8 -0
- data/lib/maestrano/api/error/invalid_request_error.rb +14 -0
- data/lib/maestrano/api/list_object.rb +37 -0
- data/lib/maestrano/api/object.rb +187 -0
- data/lib/maestrano/api/operation/base.rb +215 -0
- data/lib/maestrano/api/operation/create.rb +18 -0
- data/lib/maestrano/api/operation/delete.rb +13 -0
- data/lib/maestrano/api/operation/list.rb +18 -0
- data/lib/maestrano/api/operation/update.rb +59 -0
- data/lib/maestrano/api/resource.rb +47 -0
- data/lib/maestrano/api/util.rb +122 -0
- data/lib/maestrano/open_struct.rb +11 -0
- data/lib/maestrano/saml/attribute_value.rb +15 -0
- data/lib/maestrano/saml/metadata.rb +64 -0
- data/lib/maestrano/saml/request.rb +93 -0
- data/lib/maestrano/saml/response.rb +201 -0
- data/lib/maestrano/saml/schemas/saml20assertion_schema.xsd +283 -0
- data/lib/maestrano/saml/schemas/saml20protocol_schema.xsd +302 -0
- data/lib/maestrano/saml/schemas/xenc_schema.xsd +146 -0
- data/lib/maestrano/saml/schemas/xmldsig_schema.xsd +318 -0
- data/lib/maestrano/saml/settings.rb +37 -0
- data/lib/maestrano/saml/validation_error.rb +7 -0
- data/lib/maestrano/sso.rb +86 -0
- data/lib/maestrano/sso/base_group.rb +31 -0
- data/lib/maestrano/sso/base_membership.rb +25 -0
- data/lib/maestrano/sso/base_user.rb +75 -0
- data/lib/maestrano/sso/group.rb +24 -0
- data/lib/maestrano/sso/session.rb +107 -0
- data/lib/maestrano/sso/user.rb +34 -0
- data/lib/maestrano/version.rb +3 -0
- data/lib/maestrano/xml_security/signed_document.rb +170 -0
- data/maestrano.gemspec +32 -0
- data/maestrano.png +0 -0
- data/test/helpers/api_helpers.rb +115 -0
- data/test/helpers/saml_helpers.rb +62 -0
- data/test/maestrano/account/bill_test.rb +48 -0
- data/test/maestrano/account/recurring_bill_test.rb +49 -0
- data/test/maestrano/api/list_object_test.rb +20 -0
- data/test/maestrano/api/object_test.rb +28 -0
- data/test/maestrano/api/resource_test.rb +343 -0
- data/test/maestrano/api/util_test.rb +31 -0
- data/test/maestrano/maestrano_test.rb +260 -0
- data/test/maestrano/open_struct_test.rb +10 -0
- data/test/maestrano/saml/request_test.rb +168 -0
- data/test/maestrano/saml/response_test.rb +290 -0
- data/test/maestrano/saml/settings_test.rb +51 -0
- data/test/maestrano/sso/base_group_test.rb +54 -0
- data/test/maestrano/sso/base_membership_test.rb +45 -0
- data/test/maestrano/sso/base_user_test.rb +114 -0
- data/test/maestrano/sso/group_test.rb +47 -0
- data/test/maestrano/sso/session_test.rb +161 -0
- data/test/maestrano/sso/user_test.rb +65 -0
- data/test/maestrano/sso_test.rb +105 -0
- data/test/maestrano/xml_security/signed_document.rb +163 -0
- data/test/support/saml/certificates/certificate1 +12 -0
- data/test/support/saml/certificates/r1_certificate2_base64 +1 -0
- data/test/support/saml/responses/adfs_response_sha1.xml +46 -0
- data/test/support/saml/responses/adfs_response_sha256.xml +46 -0
- data/test/support/saml/responses/adfs_response_sha384.xml +46 -0
- data/test/support/saml/responses/adfs_response_sha512.xml +46 -0
- data/test/support/saml/responses/no_signature_ns.xml +48 -0
- data/test/support/saml/responses/open_saml_response.xml +56 -0
- data/test/support/saml/responses/r1_response6.xml.base64 +1 -0
- data/test/support/saml/responses/response1.xml.base64 +1 -0
- data/test/support/saml/responses/response2.xml.base64 +79 -0
- data/test/support/saml/responses/response3.xml.base64 +66 -0
- data/test/support/saml/responses/response4.xml.base64 +93 -0
- data/test/support/saml/responses/response5.xml.base64 +102 -0
- data/test/support/saml/responses/response_with_ampersands.xml +139 -0
- data/test/support/saml/responses/response_with_ampersands.xml.base64 +93 -0
- data/test/support/saml/responses/response_with_multiple_attribute_values.xml +57 -0
- data/test/support/saml/responses/simple_saml_php.xml +71 -0
- data/test/support/saml/responses/starfield_response.xml.base64 +1 -0
- data/test/support/saml/responses/wrapped_response_2.xml.base64 +150 -0
- data/test/test_helper.rb +47 -0
- metadata +315 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Maestrano
|
|
2
|
+
module API
|
|
3
|
+
module Operation
|
|
4
|
+
module Update
|
|
5
|
+
def save(opts={})
|
|
6
|
+
values = serialize_params(self).merge(opts)
|
|
7
|
+
|
|
8
|
+
if @values[:metadata]
|
|
9
|
+
values[:metadata] = serialize_metadata
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
if values.length > 0
|
|
13
|
+
values.delete(:id)
|
|
14
|
+
|
|
15
|
+
response, api_token = Maestrano::API::Operation::Base.request(:put, url, @api_token, values)
|
|
16
|
+
refresh_from(response, api_token)
|
|
17
|
+
end
|
|
18
|
+
self
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def serialize_metadata
|
|
22
|
+
if @unsaved_values.include?(:metadata)
|
|
23
|
+
# the metadata object has been reassigned
|
|
24
|
+
# i.e. as object.metadata = {key => val}
|
|
25
|
+
metadata_update = @values[:metadata] # new hash
|
|
26
|
+
new_keys = metadata_update.keys.map(&:to_sym)
|
|
27
|
+
# remove keys at the server, but not known locally
|
|
28
|
+
keys_to_unset = @previous_metadata.keys - new_keys
|
|
29
|
+
keys_to_unset.each {|key| metadata_update[key] = ''}
|
|
30
|
+
|
|
31
|
+
metadata_update
|
|
32
|
+
else
|
|
33
|
+
# metadata is a Maestrano::API::Object, and can be serialized normally
|
|
34
|
+
serialize_params(@values[:metadata])
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def serialize_params(obj)
|
|
39
|
+
case obj
|
|
40
|
+
when nil
|
|
41
|
+
''
|
|
42
|
+
when Maestrano::API::Object
|
|
43
|
+
unsaved_keys = obj.instance_variable_get(:@unsaved_values)
|
|
44
|
+
obj_values = obj.instance_variable_get(:@values)
|
|
45
|
+
update_hash = {}
|
|
46
|
+
|
|
47
|
+
unsaved_keys.each do |k|
|
|
48
|
+
update_hash[k] = serialize_params(obj_values[k])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
update_hash
|
|
52
|
+
else
|
|
53
|
+
obj
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Maestrano
|
|
2
|
+
module API
|
|
3
|
+
class Resource < Maestrano::API::Object
|
|
4
|
+
def self.class_name
|
|
5
|
+
self.name.split('::').reject { |w| w.to_s == "Maestrano" }
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def self.url
|
|
9
|
+
if self == Maestrano::API::Resource
|
|
10
|
+
raise NotImplementedError.new('Maestrano::API::Resource is an abstract class. You should perform actions on its subclasses (Bill, Customer, etc.)')
|
|
11
|
+
end
|
|
12
|
+
if class_name.is_a?(Array)
|
|
13
|
+
class_name.map { |w| CGI.escape(self.underscore(w)) }.join("/") + 's'
|
|
14
|
+
else
|
|
15
|
+
"#{CGI.escape(self.underscore(class_name))}s"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def url
|
|
20
|
+
unless id = self['id']
|
|
21
|
+
raise Maestrano::API::Error::InvalidRequestError.new("Could not determine which URL to request: #{self.class} instance has invalid ID: #{id.inspect}", 'id')
|
|
22
|
+
end
|
|
23
|
+
"#{self.class.url}/#{CGI.escape(id)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def refresh
|
|
27
|
+
response, api_token = Maestrano::API::Operation::Base.request(:get, url, @api_token, @retrieve_options)
|
|
28
|
+
refresh_from(response, api_token)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.retrieve(id, api_token=nil)
|
|
33
|
+
instance = self.new(id, api_token)
|
|
34
|
+
instance.refresh
|
|
35
|
+
instance
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.underscore(string_val)
|
|
39
|
+
string_val.gsub(/::/, '/').
|
|
40
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
|
41
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
|
42
|
+
tr("-", "_").
|
|
43
|
+
downcase
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Maestrano
|
|
2
|
+
module API
|
|
3
|
+
module Util
|
|
4
|
+
def self.objects_to_ids(h)
|
|
5
|
+
case h
|
|
6
|
+
when Maestrano::API::Resource
|
|
7
|
+
h.id
|
|
8
|
+
when Hash
|
|
9
|
+
res = {}
|
|
10
|
+
h.each { |k, v| res[k] = objects_to_ids(v) unless v.nil? }
|
|
11
|
+
res
|
|
12
|
+
when Array
|
|
13
|
+
h.map { |v| objects_to_ids(v) }
|
|
14
|
+
when Time
|
|
15
|
+
h.iso8601
|
|
16
|
+
else
|
|
17
|
+
h
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.object_classes
|
|
22
|
+
@object_classes ||= {
|
|
23
|
+
'account_bill' => Maestrano::Account::Bill,
|
|
24
|
+
'account_recurring_bill' => Maestrano::Account::RecurringBill,
|
|
25
|
+
'internal_list_object' => Maestrano::API::ListObject
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.convert_to_maestrano_object(resp, api_token)
|
|
30
|
+
case resp
|
|
31
|
+
when Array
|
|
32
|
+
if resp.empty? || !resp.first[:object]
|
|
33
|
+
resp
|
|
34
|
+
else
|
|
35
|
+
list = convert_to_maestrano_object({
|
|
36
|
+
object: 'internal_list_object',
|
|
37
|
+
data:[],
|
|
38
|
+
url: convert_to_maestrano_object(resp.first, api_token).class.url
|
|
39
|
+
},api_token)
|
|
40
|
+
|
|
41
|
+
resp.each do |i|
|
|
42
|
+
list.data.push(convert_to_maestrano_object(i, api_token))
|
|
43
|
+
end
|
|
44
|
+
list
|
|
45
|
+
end
|
|
46
|
+
when Hash
|
|
47
|
+
# Try converting to a known object class. If none available, fall back to generic Maestrano::API::Object
|
|
48
|
+
object_classes.fetch(resp[:object], Maestrano::API::Object).construct_from(resp, api_token)
|
|
49
|
+
else
|
|
50
|
+
# Automatically parse iso8601 dates
|
|
51
|
+
if resp =~ /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
|
|
52
|
+
Time.iso8601(resp).utc
|
|
53
|
+
else
|
|
54
|
+
resp
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.file_readable(file)
|
|
60
|
+
# This is nominally equivalent to File.readable?, but that can
|
|
61
|
+
# report incorrect results on some more oddball filesystems
|
|
62
|
+
# (such as AFS)
|
|
63
|
+
begin
|
|
64
|
+
File.open(file) { |f| }
|
|
65
|
+
rescue
|
|
66
|
+
false
|
|
67
|
+
else
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.symbolize_names(object)
|
|
73
|
+
case object
|
|
74
|
+
when Hash
|
|
75
|
+
new = {}
|
|
76
|
+
object.each do |key, value|
|
|
77
|
+
key = (key.to_sym rescue key) || key
|
|
78
|
+
new[key] = symbolize_names(value)
|
|
79
|
+
end
|
|
80
|
+
new
|
|
81
|
+
when Array
|
|
82
|
+
object.map { |value| symbolize_names(value) }
|
|
83
|
+
else
|
|
84
|
+
object
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.url_encode(key)
|
|
89
|
+
URI.escape(key.to_s, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]"))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.flatten_params(params, parent_key=nil)
|
|
93
|
+
result = []
|
|
94
|
+
params.each do |key, value|
|
|
95
|
+
calculated_key = parent_key ? "#{parent_key}[#{url_encode(key)}]" : url_encode(key)
|
|
96
|
+
if value.is_a?(Hash)
|
|
97
|
+
result += flatten_params(value, calculated_key)
|
|
98
|
+
elsif value.is_a?(Array)
|
|
99
|
+
result += flatten_params_array(value, calculated_key)
|
|
100
|
+
else
|
|
101
|
+
result << [calculated_key, value]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.flatten_params_array(value, calculated_key)
|
|
108
|
+
result = []
|
|
109
|
+
value.each do |elem|
|
|
110
|
+
if elem.is_a?(Hash)
|
|
111
|
+
result += flatten_params(elem, calculated_key)
|
|
112
|
+
elsif elem.is_a?(Array)
|
|
113
|
+
result += flatten_params_array(elem, calculated_key)
|
|
114
|
+
else
|
|
115
|
+
result << ["#{calculated_key}[]", elem]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Maestrano
|
|
2
|
+
|
|
3
|
+
# Extebd OpenStruct to include a 'attributes'
|
|
4
|
+
# method
|
|
5
|
+
class OpenStruct < ::OpenStruct
|
|
6
|
+
# Return all object defined attributes
|
|
7
|
+
def attributes
|
|
8
|
+
(self.methods - self.class.new.methods).reject {|method| method =~ /=$/ }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Maestrano
|
|
2
|
+
module Saml
|
|
3
|
+
|
|
4
|
+
# Wrapper for AttributeValue with multiple values
|
|
5
|
+
# It is subclass of String to be backwards compatible
|
|
6
|
+
# Use AttributeValue#values to get all values as an array
|
|
7
|
+
class AttributeValue < String
|
|
8
|
+
attr_accessor :values
|
|
9
|
+
def initialize(str="", values=[])
|
|
10
|
+
@values = values
|
|
11
|
+
super(str.to_s)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "rexml/document"
|
|
2
|
+
require "rexml/xpath"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
# Class to return SP metadata based on the settings requested.
|
|
6
|
+
# Return this XML in a controller, then give that URL to the the
|
|
7
|
+
# IdP administrator. The IdP will poll the URL and your settings
|
|
8
|
+
# will be updated automatically
|
|
9
|
+
module Maestrano
|
|
10
|
+
module Saml
|
|
11
|
+
include REXML
|
|
12
|
+
class Metadata
|
|
13
|
+
def generate(settings)
|
|
14
|
+
meta_doc = REXML::Document.new
|
|
15
|
+
root = meta_doc.add_element "md:EntityDescriptor", {
|
|
16
|
+
"xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
|
|
17
|
+
}
|
|
18
|
+
sp_sso = root.add_element "md:SPSSODescriptor", {
|
|
19
|
+
"protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
20
|
+
# Metadata request need not be signed (as we don't publish our cert)
|
|
21
|
+
"AuthnRequestsSigned" => false,
|
|
22
|
+
# However we would like assertions signed if idp_cert_fingerprint or idp_cert is set
|
|
23
|
+
"WantAssertionsSigned" => (!settings.idp_cert_fingerprint.nil? || !settings.idp_cert.nil?)
|
|
24
|
+
}
|
|
25
|
+
if settings.issuer != nil
|
|
26
|
+
root.attributes["entityID"] = settings.issuer
|
|
27
|
+
end
|
|
28
|
+
if settings.assertion_consumer_logout_service_url != nil
|
|
29
|
+
sp_sso.add_element "md:SingleLogoutService", {
|
|
30
|
+
# Add this as a setting to create different bindings?
|
|
31
|
+
"Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
32
|
+
"Location" => settings.assertion_consumer_logout_service_url,
|
|
33
|
+
"ResponseLocation" => settings.assertion_consumer_logout_service_url,
|
|
34
|
+
"isDefault" => true,
|
|
35
|
+
"index" => 0
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
if settings.name_identifier_format != nil
|
|
39
|
+
name_id = sp_sso.add_element "md:NameIDFormat"
|
|
40
|
+
name_id.text = settings.name_identifier_format
|
|
41
|
+
end
|
|
42
|
+
if settings.assertion_consumer_service_url != nil
|
|
43
|
+
sp_sso.add_element "md:AssertionConsumerService", {
|
|
44
|
+
# Add this as a setting to create different bindings?
|
|
45
|
+
"Binding" => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
46
|
+
"Location" => settings.assertion_consumer_service_url,
|
|
47
|
+
"isDefault" => true,
|
|
48
|
+
"index" => 0
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
# With OpenSSO, it might be required to also include
|
|
52
|
+
# <md:RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:query="urn:oasis:names:tc:SAML:metadata:ext:query" xsi:type="query:AttributeQueryDescriptorType" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
|
|
53
|
+
# <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
|
|
54
|
+
|
|
55
|
+
meta_doc << REXML::XMLDecl.new
|
|
56
|
+
ret = ""
|
|
57
|
+
# pretty print the XML so IdP administrators can easily see what the SP supports
|
|
58
|
+
meta_doc.write(ret, 1)
|
|
59
|
+
|
|
60
|
+
ret
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "uuid"
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "rexml/document"
|
|
6
|
+
require "rexml/xpath"
|
|
7
|
+
|
|
8
|
+
module Maestrano
|
|
9
|
+
module Saml
|
|
10
|
+
include REXML
|
|
11
|
+
class Request
|
|
12
|
+
attr_accessor :settings, :params, :session
|
|
13
|
+
|
|
14
|
+
def initialize(params = {}, session = {})
|
|
15
|
+
self.settings = Maestrano::SSO.saml_settings
|
|
16
|
+
self.params = params
|
|
17
|
+
self.session = session
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def redirect_url
|
|
21
|
+
request_doc = create_authentication_xml_doc(settings)
|
|
22
|
+
request_doc.context[:attribute_quote] = :quote if self.settings.double_quote_xml_attribute_values
|
|
23
|
+
|
|
24
|
+
request = ""
|
|
25
|
+
request_doc.write(request)
|
|
26
|
+
|
|
27
|
+
request = Zlib::Deflate.deflate(request, 9)[2..-5] if self.settings.compress_request
|
|
28
|
+
base64_request = Base64.encode64(request)
|
|
29
|
+
encoded_request = CGI.escape(base64_request)
|
|
30
|
+
params_prefix = (self.settings.idp_sso_target_url =~ /\?/) ? '&' : '?'
|
|
31
|
+
request_params = "#{params_prefix}SAMLRequest=#{encoded_request}"
|
|
32
|
+
|
|
33
|
+
self.params.each_pair do |key, value|
|
|
34
|
+
request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if (request_params !~ /group_id=/) && (group_id = (self.session[:mno_group_uid] || self.session['mno_group_uid']))
|
|
38
|
+
request_params << "&group_id=#{CGI.escape(group_id.to_s)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
self.settings.idp_sso_target_url + request_params
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_authentication_xml_doc(settings)
|
|
45
|
+
uuid = "_" + UUID.new.generate
|
|
46
|
+
time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
47
|
+
# Create AuthnRequest root element using REXML
|
|
48
|
+
request_doc = REXML::Document.new
|
|
49
|
+
|
|
50
|
+
root = request_doc.add_element "samlp:AuthnRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol" }
|
|
51
|
+
root.attributes['ID'] = uuid
|
|
52
|
+
root.attributes['IssueInstant'] = time
|
|
53
|
+
root.attributes['Version'] = "2.0"
|
|
54
|
+
root.attributes['Destination'] = self.settings.idp_sso_target_url unless self.settings.idp_sso_target_url.nil?
|
|
55
|
+
root.attributes['IsPassive'] = self.settings.passive unless self.settings.passive.nil?
|
|
56
|
+
root.attributes['ProtocolBinding'] = self.settings.protocol_binding unless self.settings.protocol_binding.nil?
|
|
57
|
+
|
|
58
|
+
# Conditionally defined elements based on settings
|
|
59
|
+
if self.settings.assertion_consumer_service_url != nil
|
|
60
|
+
root.attributes["AssertionConsumerServiceURL"] = self.settings.assertion_consumer_service_url
|
|
61
|
+
end
|
|
62
|
+
if self.settings.issuer != nil
|
|
63
|
+
issuer = root.add_element "saml:Issuer", { "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
|
|
64
|
+
issuer.text = self.settings.issuer
|
|
65
|
+
end
|
|
66
|
+
if self.settings.name_identifier_format != nil
|
|
67
|
+
root.add_element "samlp:NameIDPolicy", {
|
|
68
|
+
"xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
69
|
+
# Might want to make AllowCreate a setting?
|
|
70
|
+
"AllowCreate" => "true",
|
|
71
|
+
"Format" => self.settings.name_identifier_format
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# BUG fix here -- if an authn_context is defined, add the tags with an "exact"
|
|
76
|
+
# match required for authentication to succeed. If this is not defined,
|
|
77
|
+
# the IdP will choose default rules for authentication. (Shibboleth IdP)
|
|
78
|
+
if self.settings.authn_context != nil
|
|
79
|
+
requested_context = root.add_element "samlp:RequestedAuthnContext", {
|
|
80
|
+
"xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol",
|
|
81
|
+
"Comparison" => "exact",
|
|
82
|
+
}
|
|
83
|
+
class_ref = requested_context.add_element "saml:AuthnContextClassRef", {
|
|
84
|
+
"xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion",
|
|
85
|
+
}
|
|
86
|
+
class_ref.text = self.settings.authn_context
|
|
87
|
+
end
|
|
88
|
+
request_doc
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "nokogiri"
|
|
3
|
+
|
|
4
|
+
# Only supports SAML 2.0
|
|
5
|
+
module Maestrano
|
|
6
|
+
module Saml
|
|
7
|
+
|
|
8
|
+
class Response
|
|
9
|
+
ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
|
|
10
|
+
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
|
|
11
|
+
DSIG = "http://www.w3.org/2000/09/xmldsig#"
|
|
12
|
+
|
|
13
|
+
# TODO: This should probably be ctor initialized too... WDYT?
|
|
14
|
+
attr_accessor :settings
|
|
15
|
+
|
|
16
|
+
attr_reader :options
|
|
17
|
+
attr_reader :response
|
|
18
|
+
attr_reader :document
|
|
19
|
+
|
|
20
|
+
def initialize(response, options = {})
|
|
21
|
+
raise ArgumentError.new("Response cannot be nil") if response.nil?
|
|
22
|
+
@options = options
|
|
23
|
+
@response = (response =~ /^</) ? response : Base64.decode64(response)
|
|
24
|
+
@document = Maestrano::XMLSecurity::SignedDocument.new(@response)
|
|
25
|
+
@settings = Maestrano::SSO.saml_settings
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def is_valid?
|
|
29
|
+
validate
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate!
|
|
33
|
+
validate(false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The value of the user identifier as designated by the initialization request response
|
|
37
|
+
def name_id
|
|
38
|
+
@name_id ||= begin
|
|
39
|
+
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
|
|
40
|
+
node.nil? ? nil : node.text
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sessionindex
|
|
45
|
+
@sessionindex ||= begin
|
|
46
|
+
node = xpath_first_from_signed_assertion('/a:AuthnStatement')
|
|
47
|
+
node.nil? ? nil : node.attributes['SessionIndex']
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A hash of all the attributes with the response.
|
|
52
|
+
# Multiple values will be returned in the AttributeValue#values array
|
|
53
|
+
# in reverse order, when compared to XML
|
|
54
|
+
def attributes
|
|
55
|
+
@attr_statements ||= begin
|
|
56
|
+
result = {}
|
|
57
|
+
|
|
58
|
+
stmt_element = xpath_first_from_signed_assertion('/a:AttributeStatement')
|
|
59
|
+
return {} if stmt_element.nil?
|
|
60
|
+
|
|
61
|
+
stmt_element.elements.each do |attr_element|
|
|
62
|
+
name = attr_element.attributes["Name"]
|
|
63
|
+
values = attr_element.elements.collect(&:text)
|
|
64
|
+
|
|
65
|
+
# Set up a string-like wrapper for the values array
|
|
66
|
+
attr_value = AttributeValue.new(values.first, values.reverse)
|
|
67
|
+
# Merge values if the Attribute has already been seen
|
|
68
|
+
if result[name]
|
|
69
|
+
attr_value.values += result[name].values
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result[name] = attr_value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
result.keys.each do |key|
|
|
76
|
+
result[key.intern] = result[key]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# When this user session should expire at latest
|
|
84
|
+
def session_expires_at
|
|
85
|
+
@expires_at ||= begin
|
|
86
|
+
node = xpath_first_from_signed_assertion('/a:AuthnStatement')
|
|
87
|
+
parse_time(node, "SessionNotOnOrAfter")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Checks the status of the response for a "Success" code
|
|
92
|
+
def success?
|
|
93
|
+
@status_code ||= begin
|
|
94
|
+
node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION })
|
|
95
|
+
node.attributes["Value"] == "urn:oasis:names:tc:SAML:2.0:status:Success"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Conditions (if any) for the assertion to run
|
|
100
|
+
def conditions
|
|
101
|
+
@conditions ||= xpath_first_from_signed_assertion('/a:Conditions')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def not_before
|
|
105
|
+
@not_before ||= parse_time(conditions, "NotBefore")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def not_on_or_after
|
|
109
|
+
@not_on_or_after ||= parse_time(conditions, "NotOnOrAfter")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def issuer
|
|
113
|
+
@issuer ||= begin
|
|
114
|
+
node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
|
|
115
|
+
node ||= xpath_first_from_signed_assertion('/a:Issuer')
|
|
116
|
+
node.nil? ? nil : node.text
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def validation_error(message)
|
|
123
|
+
raise ValidationError.new(message)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate(soft = true)
|
|
127
|
+
validate_structure(soft) &&
|
|
128
|
+
validate_response_state(soft) &&
|
|
129
|
+
validate_conditions(soft) &&
|
|
130
|
+
document.validate_document(get_fingerprint, soft) &&
|
|
131
|
+
success?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_structure(soft = true)
|
|
135
|
+
Dir.chdir(File.expand_path(File.join(File.dirname(__FILE__), 'schemas'))) do
|
|
136
|
+
@schema = Nokogiri::XML::Schema(IO.read('saml20protocol_schema.xsd'))
|
|
137
|
+
@xml = Nokogiri::XML(self.document.to_s)
|
|
138
|
+
end
|
|
139
|
+
if soft
|
|
140
|
+
@schema.validate(@xml).map{ return false }
|
|
141
|
+
else
|
|
142
|
+
@schema.validate(@xml).map{ |error| validation_error("#{error.message}\n\n#{@xml.to_s}") }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_response_state(soft = true)
|
|
147
|
+
if response.empty?
|
|
148
|
+
return soft ? false : validation_error("Blank response")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if settings.nil?
|
|
152
|
+
return soft ? false : validation_error("No settings on response")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
|
|
156
|
+
return soft ? false : validation_error("No fingerprint or certificate on settings")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def xpath_first_from_signed_assertion(subelt=nil)
|
|
163
|
+
node = REXML::XPath.first(document, "/p:Response/a:Assertion[@ID='#{document.signed_element_id}']#{subelt}", { "p" => PROTOCOL, "a" => ASSERTION })
|
|
164
|
+
node ||= REXML::XPath.first(document, "/p:Response[@ID='#{document.signed_element_id}']/a:Assertion#{subelt}", { "p" => PROTOCOL, "a" => ASSERTION })
|
|
165
|
+
node
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get_fingerprint
|
|
169
|
+
if settings.idp_cert
|
|
170
|
+
cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
|
|
171
|
+
Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
|
|
172
|
+
else
|
|
173
|
+
settings.idp_cert_fingerprint
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_conditions(soft = true)
|
|
178
|
+
return true if conditions.nil?
|
|
179
|
+
return true if options[:skip_conditions]
|
|
180
|
+
|
|
181
|
+
now = Time.now.utc
|
|
182
|
+
|
|
183
|
+
if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before
|
|
184
|
+
return soft ? false : validation_error("Current time is earlier than NotBefore condition")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
if not_on_or_after && now >= not_on_or_after
|
|
188
|
+
return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_time(node, attribute)
|
|
195
|
+
if node && node.attributes[attribute]
|
|
196
|
+
Time.parse(node.attributes[attribute])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|