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,117 @@
|
|
1
|
+
require 'cgi'
|
2
|
+
require 'zlib'
|
3
|
+
require 'base64'
|
4
|
+
require "nokogiri"
|
5
|
+
require "rexml/document"
|
6
|
+
require "rexml/xpath"
|
7
|
+
require "xmlenc"
|
8
|
+
require "thread"
|
9
|
+
|
10
|
+
module Samlsso
|
11
|
+
class SamlMessage
|
12
|
+
include REXML
|
13
|
+
|
14
|
+
ASSERTION = "urn:oasis:names:tc:SAML:2.0:assertion"
|
15
|
+
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
|
16
|
+
|
17
|
+
def self.schema
|
18
|
+
@schema ||= Mutex.new.synchronize do
|
19
|
+
Dir.chdir(File.expand_path("../../../schemas", __FILE__)) do
|
20
|
+
::Nokogiri::XML::Schema(File.read("saml-schema-protocol-2.0.xsd"))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def valid_saml?(document, soft = true)
|
26
|
+
xml = Nokogiri::XML(document.to_s)
|
27
|
+
|
28
|
+
SamlMessage.schema.validate(xml).map do |error|
|
29
|
+
break false if soft
|
30
|
+
validation_error("#{error.message}\n\n#{xml.to_s}")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def validation_error(message)
|
35
|
+
raise ValidationError.new(message)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def decrypt_saml(decoded_saml, private_key_file_path=nil)
|
41
|
+
noko_xml = Nokogiri::XML(decoded_saml)
|
42
|
+
if (noko_xml.xpath('//saml:EncryptedAssertion', { :saml => ASSERTION }).count > 0)
|
43
|
+
raise ArgumentError, "Decryption Key File Path not provided for Encrypted Assertion" if private_key_file_path.nil?
|
44
|
+
key_pem = File.read(private_key_file_path)
|
45
|
+
encrypted_response = Xmlenc::EncryptedDocument.new(decoded_saml)
|
46
|
+
private_key = OpenSSL::PKey::RSA.new(key_pem)
|
47
|
+
decrypted_string = encrypted_response.decrypt(private_key)
|
48
|
+
decrypted_doc = Nokogiri::XML(decrypted_string) do |config|
|
49
|
+
# config.strict.nonet # for an ideal world
|
50
|
+
end
|
51
|
+
saml_namespace = {:saml => ASSERTION}
|
52
|
+
assertion = decrypted_doc.xpath("//saml:EncryptedAssertion/saml:Assertion", saml_namespace)
|
53
|
+
assertion = decrypted_doc.xpath("//saml:assertion", saml_namespace) if assertion.empty?
|
54
|
+
assertion = decrypted_doc.xpath("//saml:Assertion", saml_namespace) if assertion.empty?
|
55
|
+
assertion = decrypted_doc.xpath("//saml:ASSERTION", saml_namespace) if assertion.empty?
|
56
|
+
|
57
|
+
encrypted_assertion = decrypted_doc.xpath("//saml:EncryptedAssertion", saml_namespace)
|
58
|
+
encrypted_assertion = decrypted_doc.xpath("//saml:encryptedassertion", saml_namespace) if encrypted_assertion.empty?
|
59
|
+
encrypted_assertion = decrypted_doc.xpath("//saml:Encryptedassertion", saml_namespace) if encrypted_assertion.empty?
|
60
|
+
encrypted_assertion = decrypted_doc.xpath("//saml:ENCRYPTEDASSERTION", saml_namespace) if encrypted_assertion.empty?
|
61
|
+
|
62
|
+
if assertion.empty?
|
63
|
+
validation_error("XML document seems to be malformed and does not have correct Nodes")
|
64
|
+
else
|
65
|
+
encrypted_assertion.remove
|
66
|
+
decrypted_doc.root.add_child(assertion.last)
|
67
|
+
return decrypted_doc.to_xml.squish
|
68
|
+
end
|
69
|
+
end
|
70
|
+
return decoded_saml
|
71
|
+
end
|
72
|
+
|
73
|
+
def decode_raw_saml(saml)
|
74
|
+
if saml =~ /^</
|
75
|
+
return saml
|
76
|
+
elsif (decoded = decode(saml)) =~ /^</
|
77
|
+
return decoded
|
78
|
+
elsif (inflated = inflate(decoded)) =~ /^</
|
79
|
+
return inflated
|
80
|
+
end
|
81
|
+
|
82
|
+
return nil
|
83
|
+
end
|
84
|
+
|
85
|
+
def encode_raw_saml(saml, settings)
|
86
|
+
saml = Zlib::Deflate.deflate(saml, 9)[2..-5] if settings.compress_request
|
87
|
+
base64_saml = Base64.encode64(saml)
|
88
|
+
return CGI.escape(base64_saml)
|
89
|
+
end
|
90
|
+
|
91
|
+
def decode(encoded)
|
92
|
+
Base64.decode64(encoded)
|
93
|
+
end
|
94
|
+
|
95
|
+
def encode(encoded)
|
96
|
+
Base64.encode64(encoded).gsub(/\n/, "")
|
97
|
+
end
|
98
|
+
|
99
|
+
def escape(unescaped)
|
100
|
+
CGI.escape(unescaped)
|
101
|
+
end
|
102
|
+
|
103
|
+
def unescape(escaped)
|
104
|
+
CGI.unescape(escaped)
|
105
|
+
end
|
106
|
+
|
107
|
+
def inflate(deflated)
|
108
|
+
zlib = Zlib::Inflate.new(-Zlib::MAX_WBITS)
|
109
|
+
zlib.inflate(deflated)
|
110
|
+
end
|
111
|
+
|
112
|
+
def deflate(inflated)
|
113
|
+
Zlib::Deflate.deflate(inflated, 9)[2..-5]
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module Samlsso
|
2
|
+
class Settings
|
3
|
+
def initialize(overrides = {})
|
4
|
+
config = DEFAULTS.merge(overrides)
|
5
|
+
config.each do |k,v|
|
6
|
+
acc = "#{k.to_s}=".to_sym
|
7
|
+
self.send(acc, v) if self.respond_to? acc
|
8
|
+
end
|
9
|
+
@attribute_consuming_service = AttributeService.new
|
10
|
+
end
|
11
|
+
|
12
|
+
# IdP Data
|
13
|
+
attr_accessor :idp_entity_id
|
14
|
+
attr_accessor :idp_sso_target_url
|
15
|
+
attr_accessor :idp_slo_target_url
|
16
|
+
attr_accessor :idp_cert
|
17
|
+
attr_accessor :idp_cert_fingerprint
|
18
|
+
attr_accessor :idp_sso_is_encrypted
|
19
|
+
# SP Data
|
20
|
+
attr_accessor :issuer
|
21
|
+
attr_accessor :assertion_consumer_service_url
|
22
|
+
attr_accessor :assertion_consumer_service_binding
|
23
|
+
attr_accessor :sp_name_qualifier
|
24
|
+
attr_accessor :name_identifier_format
|
25
|
+
attr_accessor :name_identifier_value
|
26
|
+
attr_accessor :sessionindex
|
27
|
+
attr_accessor :compress_request
|
28
|
+
attr_accessor :compress_response
|
29
|
+
attr_accessor :double_quote_xml_attribute_values
|
30
|
+
attr_accessor :passive
|
31
|
+
attr_accessor :protocol_binding
|
32
|
+
attr_accessor :attributes_index
|
33
|
+
attr_accessor :force_authn
|
34
|
+
attr_accessor :security
|
35
|
+
attr_accessor :certificate
|
36
|
+
attr_accessor :private_key
|
37
|
+
attr_accessor :authn_context
|
38
|
+
attr_accessor :authn_context_comparison
|
39
|
+
attr_accessor :authn_context_decl_ref
|
40
|
+
attr_reader :attribute_consuming_service
|
41
|
+
# Compability
|
42
|
+
attr_accessor :assertion_consumer_logout_service_url
|
43
|
+
attr_accessor :assertion_consumer_logout_service_binding
|
44
|
+
|
45
|
+
def single_logout_service_url()
|
46
|
+
val = nil
|
47
|
+
if @single_logout_service_url.nil?
|
48
|
+
if @assertion_consumer_logout_service_url
|
49
|
+
val = @assertion_consumer_logout_service_url
|
50
|
+
end
|
51
|
+
else
|
52
|
+
val = @single_logout_service_url
|
53
|
+
end
|
54
|
+
val
|
55
|
+
end
|
56
|
+
|
57
|
+
# setter
|
58
|
+
def single_logout_service_url=(val)
|
59
|
+
@single_logout_service_url = val
|
60
|
+
end
|
61
|
+
|
62
|
+
def single_logout_service_binding()
|
63
|
+
val = nil
|
64
|
+
if @single_logout_service_binding.nil?
|
65
|
+
if @assertion_consumer_logout_service_binding
|
66
|
+
val = @assertion_consumer_logout_service_binding
|
67
|
+
end
|
68
|
+
else
|
69
|
+
val = @single_logout_service_binding
|
70
|
+
end
|
71
|
+
val
|
72
|
+
end
|
73
|
+
|
74
|
+
# setter
|
75
|
+
def single_logout_service_binding=(val)
|
76
|
+
@single_logout_service_binding = val
|
77
|
+
end
|
78
|
+
|
79
|
+
def get_sp_cert
|
80
|
+
cert = nil
|
81
|
+
if self.certificate
|
82
|
+
formated_cert = Samlsso::Utils.format_cert(self.certificate)
|
83
|
+
cert = OpenSSL::X509::Certificate.new(formated_cert)
|
84
|
+
end
|
85
|
+
cert
|
86
|
+
end
|
87
|
+
|
88
|
+
def get_sp_key
|
89
|
+
private_key = nil
|
90
|
+
if self.private_key
|
91
|
+
formated_private_key = Samlsso::Utils.format_private_key(self.private_key)
|
92
|
+
private_key = OpenSSL::PKey::RSA.new(formated_private_key)
|
93
|
+
end
|
94
|
+
private_key
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
DEFAULTS = {
|
100
|
+
:assertion_consumer_service_binding => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
101
|
+
:single_logout_service_binding => "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
102
|
+
:compress_request => true,
|
103
|
+
:compress_response => true,
|
104
|
+
:security => {
|
105
|
+
:authn_requests_signed => false,
|
106
|
+
:logout_requests_signed => false,
|
107
|
+
:logout_responses_signed => false,
|
108
|
+
:embed_sign => false,
|
109
|
+
:digest_method => XMLSecurity::Document::SHA1,
|
110
|
+
:signature_method => XMLSecurity::Document::SHA1
|
111
|
+
},
|
112
|
+
:double_quote_xml_attribute_values => false,
|
113
|
+
}
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'zlib'
|
2
|
+
require 'time'
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
# Only supports SAML 2.0
|
6
|
+
module Samlsso
|
7
|
+
class SloLogoutrequest < SamlMessage
|
8
|
+
attr_reader :options
|
9
|
+
attr_reader :request
|
10
|
+
attr_reader :document
|
11
|
+
|
12
|
+
def initialize(request, options = {})
|
13
|
+
raise ArgumentError.new("Request cannot be nil") if request.nil?
|
14
|
+
@options = options
|
15
|
+
@request = decode_raw_saml(request)
|
16
|
+
@document = REXML::Document.new(@request)
|
17
|
+
end
|
18
|
+
|
19
|
+
def is_valid?
|
20
|
+
validate
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate!
|
24
|
+
validate(false)
|
25
|
+
end
|
26
|
+
|
27
|
+
# The value of the user identifier as designated by the initialization request response
|
28
|
+
def name_id
|
29
|
+
@name_id ||= begin
|
30
|
+
node = REXML::XPath.first(document, "/p:LogoutRequest/a:NameID", { "p" => PROTOCOL, "a" => ASSERTION })
|
31
|
+
node.nil? ? nil : node.text
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def id
|
36
|
+
return @id if @id
|
37
|
+
element = REXML::XPath.first(document, "/p:LogoutRequest", {
|
38
|
+
"p" => PROTOCOL} )
|
39
|
+
return nil if element.nil?
|
40
|
+
return element.attributes["ID"]
|
41
|
+
end
|
42
|
+
|
43
|
+
def issuer
|
44
|
+
@issuer ||= begin
|
45
|
+
node = REXML::XPath.first(document, "/p:LogoutRequest/a:Issuer", { "p" => PROTOCOL, "a" => ASSERTION })
|
46
|
+
node.nil? ? nil : node.text
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def validate(soft = true)
|
53
|
+
valid_saml?(document, soft) && validate_request_state(soft)
|
54
|
+
end
|
55
|
+
|
56
|
+
def validate_request_state(soft = true)
|
57
|
+
if request.empty?
|
58
|
+
return soft ? false : validation_error("Blank request")
|
59
|
+
end
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require "uuid"
|
2
|
+
|
3
|
+
require "samlsso/logging"
|
4
|
+
|
5
|
+
module Samlsso
|
6
|
+
class SloLogoutresponse < 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, request_id = nil, logout_message = nil, params = {})
|
15
|
+
params = create_params(settings, request_id, logout_message, params)
|
16
|
+
params_prefix = (settings.idp_slo_target_url =~ /\?/) ? '&' : '?'
|
17
|
+
saml_response = CGI.escape(params.delete("SAMLResponse"))
|
18
|
+
response_params = "#{params_prefix}SAMLResponse=#{saml_response}"
|
19
|
+
params.each_pair do |key, value|
|
20
|
+
response_params << "&#{key.to_s}=#{CGI.escape(value.to_s)}"
|
21
|
+
end
|
22
|
+
|
23
|
+
@logout_url = settings.idp_slo_target_url + response_params
|
24
|
+
end
|
25
|
+
|
26
|
+
def create_params(settings, request_id = nil, logout_message = nil, params = {})
|
27
|
+
params = {} if params.nil?
|
28
|
+
|
29
|
+
response_doc = create_logout_response_xml_doc(settings, request_id, logout_message)
|
30
|
+
response_doc.context[:attribute_quote] = :quote if settings.double_quote_xml_attribute_values
|
31
|
+
|
32
|
+
response = ""
|
33
|
+
response_doc.write(response)
|
34
|
+
|
35
|
+
Logging.debug "Created SLO Logout Response: #{response}"
|
36
|
+
|
37
|
+
response = deflate(response) if settings.compress_response
|
38
|
+
base64_response = encode(response)
|
39
|
+
response_params = {"SAMLResponse" => base64_response}
|
40
|
+
|
41
|
+
if settings.security[:logout_responses_signed] && !settings.security[:embed_sign] && settings.private_key
|
42
|
+
params['SigAlg'] = XMLSecurity::Document::SHA1
|
43
|
+
url_string = "SAMLResponse=#{CGI.escape(base64_response)}"
|
44
|
+
url_string += "&RelayState=#{CGI.escape(params['RelayState'])}" if params['RelayState']
|
45
|
+
url_string += "&SigAlg=#{CGI.escape(params['SigAlg'])}"
|
46
|
+
private_key = settings.get_sp_key()
|
47
|
+
signature = private_key.sign(XMLSecurity::BaseDocument.new.algorithm(settings.security[:signature_method]).new, url_string)
|
48
|
+
params['Signature'] = encode(signature)
|
49
|
+
end
|
50
|
+
|
51
|
+
params.each_pair do |key, value|
|
52
|
+
response_params[key] = value.to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
response_params
|
56
|
+
end
|
57
|
+
|
58
|
+
def create_logout_response_xml_doc(settings, request_id = nil, logout_message = nil)
|
59
|
+
time = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
60
|
+
|
61
|
+
response_doc = XMLSecurity::Document.new
|
62
|
+
response_doc.uuid = uuid
|
63
|
+
|
64
|
+
root = response_doc.add_element 'samlp:LogoutResponse', { 'xmlns:samlp' => 'urn:oasis:names:tc:SAML:2.0:protocol', "xmlns:saml" => "urn:oasis:names:tc:SAML:2.0:assertion" }
|
65
|
+
root.attributes['ID'] = uuid
|
66
|
+
root.attributes['IssueInstant'] = time
|
67
|
+
root.attributes['Version'] = '2.0'
|
68
|
+
root.attributes['InResponseTo'] = request_id unless request_id.nil?
|
69
|
+
root.attributes['Destination'] = settings.idp_slo_target_url unless settings.idp_slo_target_url.nil?
|
70
|
+
|
71
|
+
# add success message
|
72
|
+
status = root.add_element 'samlp:Status'
|
73
|
+
|
74
|
+
# success status code
|
75
|
+
status_code = status.add_element 'samlp:StatusCode'
|
76
|
+
status_code.attributes['Value'] = 'urn:oasis:names:tc:SAML:2.0:status:Success'
|
77
|
+
|
78
|
+
# success status message
|
79
|
+
logout_message ||= 'Successfully Signed Out'
|
80
|
+
status_message = status.add_element 'samlp:StatusMessage'
|
81
|
+
status_message.text = logout_message
|
82
|
+
|
83
|
+
if settings.issuer != nil
|
84
|
+
issuer = root.add_element "saml:Issuer"
|
85
|
+
issuer.text = settings.issuer
|
86
|
+
end
|
87
|
+
|
88
|
+
# embebed sign
|
89
|
+
if settings.security[:logout_responses_signed] && settings.private_key && settings.certificate && settings.security[:embed_sign]
|
90
|
+
private_key = settings.get_sp_key()
|
91
|
+
cert = settings.get_sp_cert()
|
92
|
+
response_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
|
93
|
+
end
|
94
|
+
|
95
|
+
response_doc
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Samlsso
|
2
|
+
class Utils
|
3
|
+
def self.format_cert(cert, heads=true)
|
4
|
+
cert = cert.delete("\n").delete("\r").delete("\x0D")
|
5
|
+
if cert
|
6
|
+
cert = cert.gsub('-----BEGIN CERTIFICATE-----', '')
|
7
|
+
cert = cert.gsub('-----END CERTIFICATE-----', '')
|
8
|
+
cert = cert.gsub(' ', '')
|
9
|
+
|
10
|
+
if heads
|
11
|
+
cert = cert.scan(/.{1,64}/).join("\n")+"\n"
|
12
|
+
cert = "-----BEGIN CERTIFICATE-----\n" + cert + "-----END CERTIFICATE-----\n"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
cert
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.format_private_key(key, heads=true)
|
19
|
+
key = key.delete("\n").delete("\r").delete("\x0D")
|
20
|
+
if key
|
21
|
+
if key.index('-----BEGIN PRIVATE KEY-----') != nil
|
22
|
+
key = key.gsub('-----BEGIN PRIVATE KEY-----', '')
|
23
|
+
key = key.gsub('-----END PRIVATE KEY-----', '')
|
24
|
+
key = key.gsub(' ', '')
|
25
|
+
if heads
|
26
|
+
key = key.scan(/.{1,64}/).join("\n")+"\n"
|
27
|
+
key = "-----BEGIN PRIVATE KEY-----\n" + key + "-----END PRIVATE KEY-----\n"
|
28
|
+
end
|
29
|
+
else
|
30
|
+
key = key.gsub('-----BEGIN RSA PRIVATE KEY-----', '')
|
31
|
+
key = key.gsub('-----END RSA PRIVATE KEY-----', '')
|
32
|
+
key = key.gsub(' ', '')
|
33
|
+
if heads
|
34
|
+
key = key.scan(/.{1,64}/).join("\n")+"\n"
|
35
|
+
key = "-----BEGIN RSA PRIVATE KEY-----\n" + key + "-----END RSA PRIVATE KEY-----\n"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|