samlsso 0.1.0
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 +50 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +36 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/samlsso.rb +16 -0
- data/lib/samlsso/attribute_service.rb +32 -0
- data/lib/samlsso/attributes.rb +107 -0
- data/lib/samlsso/authrequest.rb +124 -0
- data/lib/samlsso/idp_metadata_parser.rb +85 -0
- data/lib/samlsso/logging.rb +20 -0
- data/lib/samlsso/logoutrequest.rb +100 -0
- data/lib/samlsso/logoutresponse.rb +110 -0
- data/lib/samlsso/metadata.rb +94 -0
- data/lib/samlsso/response.rb +271 -0
- data/lib/samlsso/saml_message.rb +117 -0
- data/lib/samlsso/settings.rb +115 -0
- data/lib/samlsso/slo_logoutrequest.rb +64 -0
- data/lib/samlsso/slo_logoutresponse.rb +99 -0
- data/lib/samlsso/utils.rb +42 -0
- data/lib/samlsso/validation_error.rb +5 -0
- data/lib/samlsso/version.rb +3 -0
- data/lib/schemas/saml-schema-assertion-2.0.xsd +283 -0
- data/lib/schemas/saml-schema-authn-context-2.0.xsd +23 -0
- data/lib/schemas/saml-schema-authn-context-types-2.0.xsd +821 -0
- data/lib/schemas/saml-schema-metadata-2.0.xsd +339 -0
- data/lib/schemas/saml-schema-protocol-2.0.xsd +302 -0
- data/lib/schemas/sstc-metadata-attr.xsd +35 -0
- data/lib/schemas/sstc-saml-attribute-ext.xsd +25 -0
- data/lib/schemas/sstc-saml-metadata-algsupport-v1.0.xsd +41 -0
- data/lib/schemas/sstc-saml-metadata-ui-v1.0.xsd +89 -0
- data/lib/schemas/xenc-schema.xsd +136 -0
- data/lib/schemas/xml.xsd +287 -0
- data/lib/schemas/xmldsig-core-schema.xsd +309 -0
- data/lib/xml_security.rb +276 -0
- data/samlsso.gemspec +44 -0
- metadata +168 -0
@@ -0,0 +1,85 @@
|
|
1
|
+
require "base64"
|
2
|
+
require "uuid"
|
3
|
+
require "zlib"
|
4
|
+
require "cgi"
|
5
|
+
require "rexml/document"
|
6
|
+
require "rexml/xpath"
|
7
|
+
|
8
|
+
module Samlsso
|
9
|
+
include REXML
|
10
|
+
|
11
|
+
class IdpMetadataParser
|
12
|
+
|
13
|
+
METADATA = "urn:oasis:names:tc:SAML:2.0:metadata"
|
14
|
+
DSIG = "http://www.w3.org/2000/09/xmldsig#"
|
15
|
+
|
16
|
+
attr_reader :document
|
17
|
+
|
18
|
+
def parse_remote(url, validate_cert = true)
|
19
|
+
idp_metadata = get_idp_metadata(url, validate_cert)
|
20
|
+
parse(idp_metadata)
|
21
|
+
end
|
22
|
+
|
23
|
+
def parse(idp_metadata)
|
24
|
+
@document = REXML::Document.new(idp_metadata)
|
25
|
+
|
26
|
+
Samlsso::Settings.new.tap do |settings|
|
27
|
+
|
28
|
+
settings.idp_sso_target_url = single_signon_service_url
|
29
|
+
settings.idp_slo_target_url = single_logout_service_url
|
30
|
+
settings.idp_cert_fingerprint = fingerprint
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# Retrieve the remote IdP metadata from the URL or a cached copy
|
37
|
+
# # returns a REXML document of the metadata
|
38
|
+
def get_idp_metadata(url, validate_cert)
|
39
|
+
uri = URI.parse(url)
|
40
|
+
if uri.scheme == "http"
|
41
|
+
response = Net::HTTP.get_response(uri)
|
42
|
+
meta_text = response.body
|
43
|
+
elsif uri.scheme == "https"
|
44
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
45
|
+
http.use_ssl = true
|
46
|
+
# Most IdPs will probably use self signed certs
|
47
|
+
if validate_cert
|
48
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
49
|
+
else
|
50
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
51
|
+
end
|
52
|
+
get = Net::HTTP::Get.new(uri.request_uri)
|
53
|
+
response = http.request(get)
|
54
|
+
meta_text = response.body
|
55
|
+
end
|
56
|
+
meta_text
|
57
|
+
end
|
58
|
+
|
59
|
+
def single_signon_service_url
|
60
|
+
node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleSignOnService/@Location", { "md" => METADATA })
|
61
|
+
node.value if node
|
62
|
+
end
|
63
|
+
|
64
|
+
def single_logout_service_url
|
65
|
+
node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:SingleLogoutService/@Location", { "md" => METADATA })
|
66
|
+
node.value if node
|
67
|
+
end
|
68
|
+
|
69
|
+
def certificate
|
70
|
+
@certificate ||= begin
|
71
|
+
node = REXML::XPath.first(document, "/md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor[@use='signing']/ds:KeyInfo/ds:X509Data/ds:X509Certificate", { "md" => METADATA, "ds" => DSIG })
|
72
|
+
Base64.decode64(node.text) if node
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def fingerprint
|
77
|
+
@fingerprint ||= begin
|
78
|
+
if certificate
|
79
|
+
cert = OpenSSL::X509::Certificate.new(certificate)
|
80
|
+
Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# Simplistic log class when we're running in Rails
|
2
|
+
module Samlsso
|
3
|
+
class Logging
|
4
|
+
def self.debug(message)
|
5
|
+
if defined? Rails
|
6
|
+
Rails.logger.debug message
|
7
|
+
else
|
8
|
+
puts message
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.info(message)
|
13
|
+
if defined? Rails
|
14
|
+
Rails.logger.info message
|
15
|
+
else
|
16
|
+
puts message
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "uuid"
|
2
|
+
|
3
|
+
require "samlsso/logging"
|
4
|
+
|
5
|
+
module Samlsso
|
6
|
+
class Logoutrequest < SamlMessage
|
7
|
+
|
8
|
+
attr_reader :uuid # Can be obtained if neccessary
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@uuid = "_" + UUID.new.generate
|
12
|
+
end
|
13
|
+
|
14
|
+
def create(settings, params={})
|
15
|
+
params = create_params(settings, params)
|
16
|
+
params_prefix = (settings.idp_slo_target_url =~ /\?/) ? '&' : '?'
|
17
|
+
saml_request = CGI.escape(params.delete("SAMLRequest"))
|
18
|
+
request_params = "#{params_prefix}SAMLRequest=#{saml_request}"
|
19
|
+
params.each_pair do |key, value|
|
20
|
+
request_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
|
21
|
+
end
|
22
|
+
@logout_url = settings.idp_slo_target_url + request_params
|
23
|
+
end
|
24
|
+
|
25
|
+
def create_params(settings, params={})
|
26
|
+
params = {} if params.nil?
|
27
|
+
|
28
|
+
request_doc = create_logout_request_xml_doc(settings)
|
29
|
+
request_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values
|
30
|
+
|
31
|
+
request = ""
|
32
|
+
request_doc.write(request)
|
33
|
+
|
34
|
+
Logging.debug "Created SLO Logout Request: #{request}"
|
35
|
+
|
36
|
+
request = deflate(request) if settings.compress_request
|
37
|
+
base64_request = encode(request)
|
38
|
+
request_params = {"SAMLRequest" => base64_request}
|
39
|
+
|
40
|
+
if settings.security[:logout_requests_signed] && !settings.security[:embed_sign] && settings.private_key
|
41
|
+
params['SigAlg'] = XMLSecurity::Document::SHA1
|
42
|
+
url_string = "SAMLRequest=#{CGI.escape(base64_request)}"
|
43
|
+
url_string += "&RelayState=#{CGI.escape(params['RelayState'])}" if params['RelayState']
|
44
|
+
url_string += "&SigAlg=#{CGI.escape(params['SigAlg'])}"
|
45
|
+
private_key = settings.get_sp_key()
|
46
|
+
signature = private_key.sign(XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method]).new, url_string)
|
47
|
+
params['Signature'] = encode(signature)
|
48
|
+
end
|
49
|
+
|
50
|
+
params.each_pair do |key, value|
|
51
|
+
request_params[key] = value.to_s
|
52
|
+
end
|
53
|
+
|
54
|
+
request_params
|
55
|
+
end
|
56
|
+
|
57
|
+
def create_logout_request_xml_doc(settings)
|
58
|
+
time = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
59
|
+
|
60
|
+
request_doc = XMLSecurity::Document.new
|
61
|
+
request_doc.uuid = uuid
|
62
|
+
|
63
|
+
root = request_doc.add_element "samlp:LogoutRequest", { "xmlns:samlp" => "urn:oasis:names:tc:SAML:2.0:protocol", "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
|
64
|
+
root.attributes['ID'] = uuid
|
65
|
+
root.attributes['IssueInstant'] = time
|
66
|
+
root.attributes['Version'] = "2.0"
|
67
|
+
root.attributes['Destination'] = settings.idp_slo_target_url unless settings.idp_slo_target_url.nil?
|
68
|
+
|
69
|
+
if settings.issuer
|
70
|
+
issuer = root.add_element "saml:Issuer"
|
71
|
+
issuer.text = settings.issuer
|
72
|
+
end
|
73
|
+
|
74
|
+
name_id = root.add_element "saml:NameID"
|
75
|
+
if settings.name_identifier_value
|
76
|
+
name_id.attributes['NameQualifier'] = settings.sp_name_qualifier if settings.sp_name_qualifier
|
77
|
+
name_id.attributes['Format'] = settings.name_identifier_format if settings.name_identifier_format
|
78
|
+
name_id.text = settings.name_identifier_value
|
79
|
+
else
|
80
|
+
# If no NameID is present in the settings we generate one
|
81
|
+
name_id.text = "_" + UUID.new.generate
|
82
|
+
name_id.attributes['Format'] = 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
|
83
|
+
end
|
84
|
+
|
85
|
+
if settings.sessionindex
|
86
|
+
sessionindex = root.add_element "samlp:SessionIndex"
|
87
|
+
sessionindex.text = settings.sessionindex
|
88
|
+
end
|
89
|
+
|
90
|
+
# embebed sign
|
91
|
+
if settings.security[:logout_requests_signed] && settings.private_key && settings.certificate && settings.security[:embed_sign]
|
92
|
+
private_key = settings.get_sp_key()
|
93
|
+
cert = settings.get_sp_cert()
|
94
|
+
request_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
|
95
|
+
end
|
96
|
+
|
97
|
+
request_doc
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require "xml_security"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
module Samlsso
|
5
|
+
class Logoutresponse < SamlMessage
|
6
|
+
# For API compability, this is mutable.
|
7
|
+
attr_accessor :settings
|
8
|
+
|
9
|
+
attr_reader :document
|
10
|
+
attr_reader :response
|
11
|
+
attr_reader :options
|
12
|
+
|
13
|
+
#
|
14
|
+
# In order to validate that the response matches a given request, append
|
15
|
+
# the option:
|
16
|
+
# :matches_request_id => REQUEST_ID
|
17
|
+
#
|
18
|
+
# It will validate that the logout response matches the ID of the request.
|
19
|
+
# You can also do this yourself through the in_response_to accessor.
|
20
|
+
#
|
21
|
+
def initialize(response, settings = nil, options = {})
|
22
|
+
raise ArgumentError.new("Logoutresponse cannot be nil") if response.nil?
|
23
|
+
self.settings = settings
|
24
|
+
|
25
|
+
@options = options
|
26
|
+
@response = decode_raw_saml(response)
|
27
|
+
@document = XMLSecurity::SignedDocument.new(@response)
|
28
|
+
end
|
29
|
+
|
30
|
+
def validate!
|
31
|
+
validate(false)
|
32
|
+
end
|
33
|
+
|
34
|
+
def validate(soft = true)
|
35
|
+
return false unless valid_saml?(document, soft) && valid_state?(soft)
|
36
|
+
|
37
|
+
valid_in_response_to?(soft) && valid_issuer?(soft) && success?(soft)
|
38
|
+
end
|
39
|
+
|
40
|
+
def success?(soft = true)
|
41
|
+
unless status_code == "urn:oasis:names:tc:SAML:2.0:status:Success"
|
42
|
+
return soft ? false : validation_error("Bad status code. Expected <urn:oasis:names:tc:SAML:2.0:status:Success>, but was: <#@status_code> ")
|
43
|
+
end
|
44
|
+
true
|
45
|
+
end
|
46
|
+
|
47
|
+
def in_response_to
|
48
|
+
@in_response_to ||= begin
|
49
|
+
node = REXML::XPath.first(document, "/p:LogoutResponse", { "p" => PROTOCOL, "a" => ASSERTION })
|
50
|
+
node.nil? ? nil : node.attributes['InResponseTo']
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def issuer
|
55
|
+
@issuer ||= begin
|
56
|
+
node = REXML::XPath.first(document, "/p:LogoutResponse/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
|
57
|
+
node ||= REXML::XPath.first(document, "/p:LogoutResponse/a:Assertion/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
|
58
|
+
node.nil? ? nil : node.text
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def status_code
|
63
|
+
@status_code ||= begin
|
64
|
+
node = REXML::XPath.first(document, "/p:LogoutResponse/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION })
|
65
|
+
node.nil? ? nil : node.attributes["Value"]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def valid_state?(soft = true)
|
72
|
+
if response.empty?
|
73
|
+
return soft ? false : validation_error("Blank response")
|
74
|
+
end
|
75
|
+
|
76
|
+
if settings.nil?
|
77
|
+
return soft ? false : validation_error("No settings on response")
|
78
|
+
end
|
79
|
+
|
80
|
+
if settings.issuer.nil?
|
81
|
+
return soft ? false : validation_error("No issuer in settings")
|
82
|
+
end
|
83
|
+
|
84
|
+
if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
|
85
|
+
return soft ? false : validation_error("No fingerprint or certificate on settings")
|
86
|
+
end
|
87
|
+
|
88
|
+
true
|
89
|
+
end
|
90
|
+
|
91
|
+
def valid_in_response_to?(soft = true)
|
92
|
+
return true unless self.options.has_key? :matches_request_id
|
93
|
+
|
94
|
+
unless self.options[:matches_request_id] == in_response_to
|
95
|
+
return soft ? false : validation_error("Response does not match the request ID, expected: <#{self.options[:matches_request_id]}>, but was: <#{in_response_to}>")
|
96
|
+
end
|
97
|
+
|
98
|
+
true
|
99
|
+
end
|
100
|
+
|
101
|
+
def valid_issuer?(soft = true)
|
102
|
+
return true if self.settings.idp_entity_id.nil? or self.issuer.nil?
|
103
|
+
|
104
|
+
unless URI.parse(self.issuer) == URI.parse(self.settings.idp_entity_id)
|
105
|
+
return soft ? false : validation_error("Doesn't match the issuer, expected: <#{self.settings.issuer}>, but was: <#{issuer}>")
|
106
|
+
end
|
107
|
+
true
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
require "rexml/document"
|
2
|
+
require "rexml/xpath"
|
3
|
+
require "uri"
|
4
|
+
|
5
|
+
require "samlsso/logging"
|
6
|
+
|
7
|
+
# Class to return SP metadata based on the settings requested.
|
8
|
+
# Return this XML in a controller, then give that URL to the the
|
9
|
+
# IdP administrator. The IdP will poll the URL and your settings
|
10
|
+
# will be updated automatically
|
11
|
+
module Samlsso
|
12
|
+
include REXML
|
13
|
+
class Metadata
|
14
|
+
def generate(settings)
|
15
|
+
meta_doc = REXML::Document.new
|
16
|
+
root = meta_doc.add_element "md:EntityDescriptor", {
|
17
|
+
"xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
|
18
|
+
}
|
19
|
+
sp_sso = root.add_element "md:SPSSODescriptor", {
|
20
|
+
"protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
|
21
|
+
"AuthnRequestsSigned" => settings.security[:authn_requests_signed],
|
22
|
+
# However we would like assertions signed if idp_cert_fingerprint or idp_cert is set
|
23
|
+
"WantAssertionsSigned" => !!(settings.idp_cert_fingerprint || settings.idp_cert)
|
24
|
+
}
|
25
|
+
if settings.issuer
|
26
|
+
root.attributes["entityID"] = settings.issuer
|
27
|
+
end
|
28
|
+
if settings.single_logout_service_url
|
29
|
+
sp_sso.add_element "md:SingleLogoutService", {
|
30
|
+
"Binding" => settings.single_logout_service_binding,
|
31
|
+
"Location" => settings.single_logout_service_url,
|
32
|
+
"ResponseLocation" => settings.single_logout_service_url,
|
33
|
+
"isDefault" => true,
|
34
|
+
"index" => 0
|
35
|
+
}
|
36
|
+
end
|
37
|
+
if settings.name_identifier_format
|
38
|
+
name_id = sp_sso.add_element "md:NameIDFormat"
|
39
|
+
name_id.text = settings.name_identifier_format
|
40
|
+
end
|
41
|
+
if settings.assertion_consumer_service_url
|
42
|
+
sp_sso.add_element "md:AssertionConsumerService", {
|
43
|
+
"Binding" => settings.assertion_consumer_service_binding,
|
44
|
+
"Location" => settings.assertion_consumer_service_url,
|
45
|
+
"isDefault" => true,
|
46
|
+
"index" => 0
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
# Add KeyDescriptor if messages will be signed
|
51
|
+
cert = settings.get_sp_cert()
|
52
|
+
if cert
|
53
|
+
kd = sp_sso.add_element "md:KeyDescriptor", { "use" => "signing" }
|
54
|
+
ki = kd.add_element "ds:KeyInfo", {"xmlns:ds" => "http://www.w3.org/2000/09/xmldsig#"}
|
55
|
+
xd = ki.add_element "ds:X509Data"
|
56
|
+
xc = xd.add_element "ds:X509Certificate"
|
57
|
+
xc.text = Base64.encode64(cert.to_der).gsub("\n", '')
|
58
|
+
end
|
59
|
+
|
60
|
+
if settings.attribute_consuming_service.configured?
|
61
|
+
sp_acs = sp_sso.add_element "md:AttributeConsumingService", {
|
62
|
+
"isDefault" => "true",
|
63
|
+
"index" => settings.attribute_consuming_service.index
|
64
|
+
}
|
65
|
+
srv_name = sp_acs.add_element "md:ServiceName", {
|
66
|
+
"xml:lang" => "en"
|
67
|
+
}
|
68
|
+
srv_name.text = settings.attribute_consuming_service.name
|
69
|
+
settings.attribute_consuming_service.attributes.each do |attribute|
|
70
|
+
sp_req_attr = sp_acs.add_element "md:RequestedAttribute", {
|
71
|
+
"NameFormat" => attribute[:name_format],
|
72
|
+
"Name" => attribute[:name],
|
73
|
+
"FriendlyName" => attribute[:friendly_name]
|
74
|
+
}
|
75
|
+
unless attribute[:attribute_value].nil?
|
76
|
+
sp_attr_val = sp_req_attr.add_element "md:AttributeValue"
|
77
|
+
sp_attr_val.text = attribute[:attribute_value]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# With OpenSSO, it might be required to also include
|
83
|
+
# <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"/>
|
84
|
+
# <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
|
85
|
+
|
86
|
+
meta_doc << REXML::XMLDecl.new("1.0", "UTF-8")
|
87
|
+
ret = ""
|
88
|
+
# pretty print the XML so IdP administrators can easily see what the SP supports
|
89
|
+
meta_doc.write(ret, 1)
|
90
|
+
|
91
|
+
return ret
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,271 @@
|
|
1
|
+
require "xml_security"
|
2
|
+
require "time"
|
3
|
+
require "nokogiri"
|
4
|
+
|
5
|
+
# Only supports SAML 2.0
|
6
|
+
module Samlsso
|
7
|
+
|
8
|
+
class Response < SamlMessage
|
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
|
+
attr_accessor :errors
|
16
|
+
|
17
|
+
attr_reader :options
|
18
|
+
attr_reader :response
|
19
|
+
attr_reader :document
|
20
|
+
attr_reader :decoded_response
|
21
|
+
attr_reader :decrypted_response
|
22
|
+
attr_reader :decoded_document
|
23
|
+
|
24
|
+
def initialize(response, options = {})
|
25
|
+
@errors = []
|
26
|
+
raise ArgumentError.new("Response cannot be nil") if response.nil?
|
27
|
+
@options = options
|
28
|
+
@decoded_response = decode_raw_saml(response)
|
29
|
+
@decrypted_response = decrypt_saml(@decoded_response, @options[:private_key_file_path])
|
30
|
+
@response = @decrypted_response
|
31
|
+
@document = XMLSecurity::SignedDocument.new(@response, @errors)
|
32
|
+
@decoded_document = XMLSecurity::SignedDocument.new(@decoded_response, @errors)
|
33
|
+
end
|
34
|
+
|
35
|
+
def is_valid?
|
36
|
+
validate
|
37
|
+
end
|
38
|
+
|
39
|
+
def validate!
|
40
|
+
validate(false)
|
41
|
+
end
|
42
|
+
|
43
|
+
def errors
|
44
|
+
@errors
|
45
|
+
end
|
46
|
+
|
47
|
+
# The value of the user identifier as designated by the initialization request response
|
48
|
+
def name_id
|
49
|
+
@name_id ||= begin
|
50
|
+
node = xpath_first_from_signed_assertion('/a:Subject/a:NameID')
|
51
|
+
node.nil? ? nil : node.text
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def sessionindex
|
56
|
+
@sessionindex ||= begin
|
57
|
+
node = xpath_first_from_signed_assertion('/a:AuthnStatement')
|
58
|
+
node.nil? ? nil : (node.attributes['SessionIndex'] ? node.attributes['SessionIndex'] : node.attributes['sessionindex'])
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns Samlsso::Attributes enumerable collection.
|
63
|
+
# All attributes can be iterated over +attributes.each+ or returned as array by +attributes.all+
|
64
|
+
#
|
65
|
+
# For backwards compatibility samlsso returns by default only the first value for a given attribute with
|
66
|
+
# attributes['name']
|
67
|
+
# To get all of the attributes, use:
|
68
|
+
# attributes.multi('name')
|
69
|
+
# Or turn off the compatibility:
|
70
|
+
# Samlsso::Attributes.single_value_compatibility = false
|
71
|
+
# Now this will return an array:
|
72
|
+
# attributes['name']
|
73
|
+
def attributes
|
74
|
+
@attr_statements ||= begin
|
75
|
+
attributes = Attributes.new
|
76
|
+
|
77
|
+
stmt_element = xpath_first_from_signed_assertion('/a:AttributeStatement')
|
78
|
+
return attributes if stmt_element.nil?
|
79
|
+
|
80
|
+
stmt_element.elements.each do |attr_element|
|
81
|
+
name = attr_element.attributes["Name"] ? attr_element.attributes["Name"] : attr_element.attributes["name"]
|
82
|
+
values = attr_element.elements.collect{|e|
|
83
|
+
# SAMLCore requires that nil AttributeValues MUST contain xsi:nil XML attribute set to "true" or "1"
|
84
|
+
# otherwise the value is to be regarded as empty.
|
85
|
+
["true", "1"].include?(e.attributes['xsi:nil']) ? nil : e.text.to_s
|
86
|
+
}
|
87
|
+
|
88
|
+
attributes.add(name, values)
|
89
|
+
end
|
90
|
+
|
91
|
+
attributes
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# When this user session should expire at latest
|
96
|
+
def session_expires_at
|
97
|
+
@expires_at ||= begin
|
98
|
+
node = xpath_first_from_signed_assertion('/a:AuthnStatement')
|
99
|
+
parse_time(node, "SessionNotOnOrAfter")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Checks the status of the response for a "Success" code
|
104
|
+
def success?
|
105
|
+
@status_code ||= begin
|
106
|
+
node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusCode", { "p" => PROTOCOL, "a" => ASSERTION })
|
107
|
+
node.attributes["Value"] == "urn:oasis:names:tc:SAML:2.0:status:Success" || node.attributes["value"] == "urn:oasis:names:tc:SAML:2.0:status:Success"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def status_message
|
112
|
+
@status_message ||= begin
|
113
|
+
node = REXML::XPath.first(document, "/p:Response/p:Status/p:StatusMessage", { "p" => PROTOCOL, "a" => ASSERTION })
|
114
|
+
node.text if node
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Conditions (if any) for the assertion to run
|
119
|
+
def conditions
|
120
|
+
@conditions ||= xpath_first_from_signed_assertion('/a:Conditions')
|
121
|
+
end
|
122
|
+
|
123
|
+
def not_before
|
124
|
+
@not_before ||= parse_time(conditions, "NotBefore")
|
125
|
+
end
|
126
|
+
|
127
|
+
def not_on_or_after
|
128
|
+
@not_on_or_after ||= parse_time(conditions, "NotOnOrAfter")
|
129
|
+
end
|
130
|
+
|
131
|
+
def issuer
|
132
|
+
@issuer ||= begin
|
133
|
+
node = REXML::XPath.first(document, "/p:Response/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
|
134
|
+
node ||= xpath_first_from_signed_assertion('/a:Issuer')
|
135
|
+
node.nil? ? nil : node.text
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def validate(soft = true)
|
142
|
+
valid_saml?(decoded_document, soft) &&
|
143
|
+
validate_response_state(soft) &&
|
144
|
+
validate_conditions(soft) &&
|
145
|
+
validate_issuer(soft) &&
|
146
|
+
decoded_document.validate_document(get_fingerprint, soft) &&
|
147
|
+
validate_success_status(soft)
|
148
|
+
end
|
149
|
+
|
150
|
+
def validate_success_status(soft = true)
|
151
|
+
if success?
|
152
|
+
true
|
153
|
+
else
|
154
|
+
soft ? false : validation_error(status_message)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def validate_structure(soft = true)
|
159
|
+
return true #temp
|
160
|
+
xml = Nokogiri::XML(self.document.to_s)
|
161
|
+
|
162
|
+
SamlMessage.schema.validate(xml).map do |error|
|
163
|
+
if soft
|
164
|
+
@errors << "Schema validation failed"
|
165
|
+
break false
|
166
|
+
else
|
167
|
+
error_message = [error.message, xml.to_s].join("\n\n")
|
168
|
+
|
169
|
+
@errors << error_message
|
170
|
+
validation_error(error_message)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def validate_response_state(soft = true)
|
176
|
+
if response.empty?
|
177
|
+
return soft ? false : validation_error("Blank response")
|
178
|
+
end
|
179
|
+
|
180
|
+
if settings.nil?
|
181
|
+
return soft ? false : validation_error("No settings on response")
|
182
|
+
end
|
183
|
+
|
184
|
+
if settings.idp_cert_fingerprint.nil? && settings.idp_cert.nil?
|
185
|
+
return soft ? false : validation_error("No fingerprint or certificate on settings")
|
186
|
+
end
|
187
|
+
|
188
|
+
true
|
189
|
+
end
|
190
|
+
|
191
|
+
def xpath_first_from_signed_assertion(subelt=nil)
|
192
|
+
id_str = ""
|
193
|
+
id_str = "[@ID=$id]" unless document.signed_element_id.blank?
|
194
|
+
node = REXML::XPath.first(
|
195
|
+
document,
|
196
|
+
"/p:Response/a:Assertion#{id_str}#{subelt}",
|
197
|
+
{ "p" => PROTOCOL, "a" => ASSERTION },
|
198
|
+
{ 'id' => document.signed_element_id }
|
199
|
+
)
|
200
|
+
node ||= REXML::XPath.first(
|
201
|
+
document,
|
202
|
+
"/p:Response#{id_str}/a:Assertion#{subelt}",
|
203
|
+
{ "p" => PROTOCOL, "a" => ASSERTION },
|
204
|
+
{ 'id' => document.signed_element_id }
|
205
|
+
)
|
206
|
+
node ||= REXML::XPath.first(
|
207
|
+
document,
|
208
|
+
"/p:Response/a:assertion#{id_str}#{subelt}",
|
209
|
+
{ "p" => PROTOCOL, "a" => ASSERTION },
|
210
|
+
{ 'id' => document.signed_element_id }
|
211
|
+
)
|
212
|
+
node ||= REXML::XPath.first(
|
213
|
+
document,
|
214
|
+
"/p:Response#{id_str}/a:assertion#{subelt}",
|
215
|
+
{ "p" => PROTOCOL, "a" => ASSERTION },
|
216
|
+
{ 'id' => document.signed_element_id }
|
217
|
+
)
|
218
|
+
node ||= REXML::XPath.first(
|
219
|
+
document,
|
220
|
+
"/p:Response#{id_str}/a:assertion#{subelt.downcase}",
|
221
|
+
{ "p" => PROTOCOL, "a" => ASSERTION },
|
222
|
+
{ 'id' => document.signed_element_id }
|
223
|
+
)
|
224
|
+
node
|
225
|
+
end
|
226
|
+
|
227
|
+
def get_fingerprint
|
228
|
+
if settings.idp_cert
|
229
|
+
cert = OpenSSL::X509::Certificate.new(settings.idp_cert)
|
230
|
+
Digest::SHA1.hexdigest(cert.to_der).upcase.scan(/../).join(":")
|
231
|
+
else
|
232
|
+
settings.idp_cert_fingerprint
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
def validate_conditions(soft = true)
|
237
|
+
return true if conditions.nil?
|
238
|
+
return true if options[:skip_conditions]
|
239
|
+
|
240
|
+
now = Time.now.utc
|
241
|
+
|
242
|
+
if not_before && (now + (options[:allowed_clock_drift] || 0)) < not_before
|
243
|
+
@errors << "Current time is earlier than NotBefore condition #{(now + (options[:allowed_clock_drift] || 0))} < #{not_before})"
|
244
|
+
return soft ? false : validation_error("Current time is earlier than NotBefore condition")
|
245
|
+
end
|
246
|
+
|
247
|
+
if not_on_or_after && now >= not_on_or_after
|
248
|
+
@errors << "Current time is on or after NotOnOrAfter condition (#{now} >= #{not_on_or_after})"
|
249
|
+
return soft ? false : validation_error("Current time is on or after NotOnOrAfter condition")
|
250
|
+
end
|
251
|
+
|
252
|
+
true
|
253
|
+
end
|
254
|
+
|
255
|
+
def validate_issuer(soft = true)
|
256
|
+
return true if settings.idp_entity_id.nil?
|
257
|
+
|
258
|
+
unless URI.parse(issuer) == URI.parse(settings.idp_entity_id)
|
259
|
+
return soft ? false : validation_error("Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>")
|
260
|
+
end
|
261
|
+
true
|
262
|
+
end
|
263
|
+
|
264
|
+
def parse_time(node, attribute)
|
265
|
+
if node
|
266
|
+
attrs = node.attributes[attribute] ? node.attributes[attribute] : node.attributes[attribute.downcase]
|
267
|
+
Time.parse(attrs) if attrs
|
268
|
+
end
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|