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