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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +50 -0
  3. data/CODE_OF_CONDUCT.md +49 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE +21 -0
  6. data/README.md +36 -0
  7. data/Rakefile +2 -0
  8. data/bin/console +14 -0
  9. data/bin/setup +8 -0
  10. data/lib/samlsso.rb +16 -0
  11. data/lib/samlsso/attribute_service.rb +32 -0
  12. data/lib/samlsso/attributes.rb +107 -0
  13. data/lib/samlsso/authrequest.rb +124 -0
  14. data/lib/samlsso/idp_metadata_parser.rb +85 -0
  15. data/lib/samlsso/logging.rb +20 -0
  16. data/lib/samlsso/logoutrequest.rb +100 -0
  17. data/lib/samlsso/logoutresponse.rb +110 -0
  18. data/lib/samlsso/metadata.rb +94 -0
  19. data/lib/samlsso/response.rb +271 -0
  20. data/lib/samlsso/saml_message.rb +117 -0
  21. data/lib/samlsso/settings.rb +115 -0
  22. data/lib/samlsso/slo_logoutrequest.rb +64 -0
  23. data/lib/samlsso/slo_logoutresponse.rb +99 -0
  24. data/lib/samlsso/utils.rb +42 -0
  25. data/lib/samlsso/validation_error.rb +5 -0
  26. data/lib/samlsso/version.rb +3 -0
  27. data/lib/schemas/saml-schema-assertion-2.0.xsd +283 -0
  28. data/lib/schemas/saml-schema-authn-context-2.0.xsd +23 -0
  29. data/lib/schemas/saml-schema-authn-context-types-2.0.xsd +821 -0
  30. data/lib/schemas/saml-schema-metadata-2.0.xsd +339 -0
  31. data/lib/schemas/saml-schema-protocol-2.0.xsd +302 -0
  32. data/lib/schemas/sstc-metadata-attr.xsd +35 -0
  33. data/lib/schemas/sstc-saml-attribute-ext.xsd +25 -0
  34. data/lib/schemas/sstc-saml-metadata-algsupport-v1.0.xsd +41 -0
  35. data/lib/schemas/sstc-saml-metadata-ui-v1.0.xsd +89 -0
  36. data/lib/schemas/xenc-schema.xsd +136 -0
  37. data/lib/schemas/xml.xsd +287 -0
  38. data/lib/schemas/xmldsig-core-schema.xsd +309 -0
  39. data/lib/xml_security.rb +276 -0
  40. data/samlsso.gemspec +44 -0
  41. 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
@@ -0,0 +1,5 @@
1
+ module Samlsso
2
+ class ValidationError < StandardError
3
+ end
4
+ end
5
+