samlr 2.0.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of samlr might be problematic. Click here for more details.

Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +3 -0
  3. data/.travis.yml +5 -0
  4. data/Gemfile +8 -0
  5. data/LICENSE +176 -0
  6. data/README.md +182 -0
  7. data/Rakefile +12 -0
  8. data/bin/samlr +46 -0
  9. data/config/schemas/XMLSchema.xsd +2534 -0
  10. data/config/schemas/saml-schema-assertion-2.0.xsd +283 -0
  11. data/config/schemas/saml-schema-metadata-2.0.xsd +337 -0
  12. data/config/schemas/saml-schema-protocol-2.0.xsd +302 -0
  13. data/config/schemas/xenc-schema.xsd +146 -0
  14. data/config/schemas/xml.xsd +287 -0
  15. data/config/schemas/xmldsig-core-schema.xsd +318 -0
  16. data/lib/samlr.rb +52 -0
  17. data/lib/samlr/assertion.rb +91 -0
  18. data/lib/samlr/certificate.rb +23 -0
  19. data/lib/samlr/command.rb +41 -0
  20. data/lib/samlr/condition.rb +31 -0
  21. data/lib/samlr/errors.rb +22 -0
  22. data/lib/samlr/fingerprint.rb +44 -0
  23. data/lib/samlr/logout_request.rb +7 -0
  24. data/lib/samlr/reference.rb +32 -0
  25. data/lib/samlr/request.rb +37 -0
  26. data/lib/samlr/response.rb +68 -0
  27. data/lib/samlr/signature.rb +129 -0
  28. data/lib/samlr/tools.rb +108 -0
  29. data/lib/samlr/tools/certificate_builder.rb +74 -0
  30. data/lib/samlr/tools/logout_request_builder.rb +27 -0
  31. data/lib/samlr/tools/metadata_builder.rb +41 -0
  32. data/lib/samlr/tools/request_builder.rb +44 -0
  33. data/lib/samlr/tools/response_builder.rb +157 -0
  34. data/lib/samlr/tools/timestamp.rb +26 -0
  35. data/samlr.gemspec +19 -0
  36. data/test/fixtures/default_samlr_certificate.pem +11 -0
  37. data/test/fixtures/default_samlr_private_key.pem +9 -0
  38. data/test/fixtures/no_cert_response.xml +2 -0
  39. data/test/fixtures/sample_metadata.xml +7 -0
  40. data/test/fixtures/sample_response.xml +2 -0
  41. data/test/test_helper.rb +55 -0
  42. data/test/unit/test_assertion.rb +54 -0
  43. data/test/unit/test_condition.rb +71 -0
  44. data/test/unit/test_fingerprint.rb +45 -0
  45. data/test/unit/test_logout_request.rb +39 -0
  46. data/test/unit/test_reference.rb +32 -0
  47. data/test/unit/test_request.rb +34 -0
  48. data/test/unit/test_response.rb +94 -0
  49. data/test/unit/test_response_scenarios.rb +111 -0
  50. data/test/unit/test_signature.rb +54 -0
  51. data/test/unit/test_timestamp.rb +58 -0
  52. data/test/unit/test_tools.rb +100 -0
  53. data/test/unit/tools/test_certificate_builder.rb +41 -0
  54. data/test/unit/tools/test_logout_request_builder.rb +26 -0
  55. data/test/unit/tools/test_metadata_builder.rb +26 -0
  56. data/test/unit/tools/test_request_builder.rb +35 -0
  57. data/test/unit/tools/test_response_builder.rb +19 -0
  58. metadata +184 -0
@@ -0,0 +1,108 @@
1
+ require "time"
2
+ require "uuidtools"
3
+ require "openssl"
4
+ require "cgi"
5
+ require "zlib"
6
+
7
+ require "samlr/tools/timestamp"
8
+ require "samlr/tools/certificate_builder"
9
+ require "samlr/tools/request_builder"
10
+ require "samlr/tools/response_builder"
11
+ require "samlr/tools/metadata_builder"
12
+ require "samlr/tools/logout_request_builder"
13
+
14
+ module Samlr
15
+ module Tools
16
+ SHA_MAP = {
17
+ 1 => OpenSSL::Digest::SHA1,
18
+ 256 => OpenSSL::Digest::SHA256,
19
+ 384 => OpenSSL::Digest::SHA384,
20
+ 512 => OpenSSL::Digest::SHA512
21
+ }
22
+
23
+ # Convert algorithm attribute value to Ruby implementation
24
+ def self.algorithm(value)
25
+ if value =~ /sha(\d+)$/
26
+ implementation = SHA_MAP[$1.to_i]
27
+ end
28
+
29
+ implementation || OpenSSL::Digest::SHA1
30
+ end
31
+
32
+ # Accepts a document and optionally :path => xpath, :c14n_mode => c14n_mode
33
+ def self.canonicalize(xml, options = {})
34
+ options = { :c14n_mode => C14N }.merge(options)
35
+ document = Nokogiri::XML(xml) { |c| c.strict.noblanks }
36
+
37
+ if path = options[:path]
38
+ node = document.at(path, NS_MAP)
39
+ else
40
+ node = document
41
+ end
42
+
43
+ node.canonicalize(options[:c14n_mode], options[:namespaces])
44
+ end
45
+
46
+ # Generate an xs:NCName conforming UUID
47
+ def self.uuid
48
+ "samlr-#{UUIDTools::UUID.timestamp_create}"
49
+ end
50
+
51
+ # Deflates, Base64 encodes and CGI escapes a string
52
+ def self.encode(string)
53
+ deflated = Zlib::Deflate.deflate(string, 9)[2..-5]
54
+ encoded = Base64.encode64(deflated)
55
+ escaped = CGI.escape(encoded)
56
+ escaped
57
+ end
58
+
59
+ # CGI unescapes, Base64 decodes and inflates a string
60
+ def self.decode(string)
61
+ unescaped = CGI.unescape(string)
62
+ decoded = Base64.decode64(unescaped)
63
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
64
+ inflated = inflater.inflate(decoded)
65
+
66
+ inflater.finish
67
+ inflater.close
68
+
69
+ inflated
70
+ end
71
+
72
+ def self.validate!(options = {})
73
+ validate(options.merge(:bang => true))
74
+ end
75
+
76
+ # Validate a SAML request or response against an XSD. Supply either :path or :document in the options and
77
+ # a :schema (defaults to SAML validation)
78
+ def self.validate(options = {})
79
+ document = options[:document] || File.read(options[:path])
80
+ schema = options.fetch(:schema, SAML_SCHEMA)
81
+ bang = options.fetch(:bang, false)
82
+
83
+ if document.is_a?(Nokogiri::XML::Document)
84
+ xml = document
85
+ else
86
+ xml = Nokogiri::XML(document) { |c| c.strict }
87
+ end
88
+
89
+ # All bundled schemas are using relative schemaLocation. This means we'll have to
90
+ # change working directory to find them during validation.
91
+ Dir.chdir(Samlr.schema_location) do
92
+ if schema.is_a?(Nokogiri::XML::Schema)
93
+ xsd = schema
94
+ else
95
+ xsd = Nokogiri::XML::Schema(File.read(schema))
96
+ end
97
+
98
+ result = xsd.validate(xml)
99
+
100
+ if bang && result.length != 0
101
+ raise Samlr::FormatError.new("Schema validation failed", "XSD validation errors: #{result.join(", ")}")
102
+ else
103
+ result.length == 0
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,74 @@
1
+ module Samlr
2
+ module Tools
3
+
4
+ # Container for generating/referencing X509 and keys
5
+ class CertificateBuilder
6
+ attr_reader :key_size
7
+
8
+ def initialize(options = {})
9
+ @key_size = options.fetch(:key_size, 4096)
10
+ @x509 = options[:x509]
11
+ @key_pair = options[:key_pair]
12
+ end
13
+
14
+ def x509
15
+ @x509 ||= begin
16
+ domain = "example.org"
17
+ name = OpenSSL::X509::Name.new([
18
+ [ 'C', 'US', OpenSSL::ASN1::PRINTABLESTRING ],
19
+ [ 'O', domain, OpenSSL::ASN1::UTF8STRING ],
20
+ [ 'OU', 'Samlr ResponseBuilder', OpenSSL::ASN1::UTF8STRING ],
21
+ [ 'CN', 'CA' ]
22
+ ])
23
+
24
+ certificate = OpenSSL::X509::Certificate.new
25
+ certificate.subject = name
26
+ certificate.issuer = name
27
+ certificate.not_before = (Time.now - 5)
28
+ certificate.not_after = (Time.now + 60 * 60 * 24 * 365 * 20)
29
+ certificate.public_key = key_pair.public_key
30
+ certificate.serial = 1
31
+ certificate.version = 2
32
+ certificate.sign(key_pair, OpenSSL::Digest::SHA1.new)
33
+
34
+ certificate
35
+ end
36
+ end
37
+
38
+ def x509_as_pem
39
+ pem = x509.to_pem.split("\n")
40
+ pem.pop
41
+ pem.shift
42
+ pem.join
43
+ end
44
+
45
+ def key_pair
46
+ @key_pair ||= OpenSSL::PKey::RSA.new(key_size)
47
+ end
48
+
49
+ def sign(string)
50
+ Base64.encode64(key_pair.sign(OpenSSL::Digest::SHA1.new, string)).delete("\n")
51
+ end
52
+
53
+ def verify(signature, string)
54
+ key_pair.public_key.verify(OpenSSL::Digest::SHA1.new, Base64.decode64(signature), string)
55
+ end
56
+
57
+ def to_certificate
58
+ Samlr::Certificate.new(x509)
59
+ end
60
+
61
+ def self.dump(path, certificate, id = "samlr")
62
+ File.open(File.join(path, "#{id}_private_key.pem"), "w") { |f| f.write(certificate.key_pair.to_pem) }
63
+ File.open(File.join(path, "#{id}_certificate.pem"), "w") { |f| f.write(certificate.x509.to_pem) }
64
+ end
65
+
66
+ def self.load(path, id = "samlr")
67
+ key_pair = OpenSSL::PKey::RSA.new(File.read(File.join(path, "#{id}_private_key.pem")))
68
+ x509_cert = OpenSSL::X509::Certificate.new(File.read(File.join(path, "#{id}_certificate.pem")))
69
+
70
+ new(:key_pair => key_pair, :x509 => x509_cert)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,27 @@
1
+ require "nokogiri"
2
+
3
+ module Samlr
4
+ module Tools
5
+ # Use this for building the SAML logout request XML
6
+ module LogoutRequestBuilder
7
+ def self.build(options = {})
8
+ name_id_format = options[:name_id_format] || EMAIL_FORMAT
9
+
10
+ # Mandatory
11
+ name_id = options.fetch(:name_id)
12
+ issuer = options.fetch(:issuer)
13
+
14
+ builder = Nokogiri::XML::Builder.new do |xml|
15
+ xml.LogoutRequest("xmlns:samlp" => NS_MAP["samlp"], "xmlns:saml" => NS_MAP["saml"], "ID" => Samlr::Tools.uuid, "IssueInstant" => Samlr::Tools::Timestamp.stamp, "Version" => "2.0") do
16
+ xml.doc.root.namespace = xml.doc.root.namespace_definitions.find { |ns| ns.prefix == "samlp" }
17
+
18
+ xml["saml"].Issuer(issuer)
19
+ xml["saml"].NameID(name_id, "Format" => name_id_format)
20
+ end
21
+ end
22
+
23
+ builder.to_xml(COMPACT)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,41 @@
1
+ module Samlr
2
+ module Tools
3
+
4
+ # Builds you some SP metadata. Accepts a hash with the below keys. No support for arrays
5
+ # of name id formats or asserion consumer services, build it if you need it.
6
+ #
7
+ # :entity_id => "https://sp.example.org/saml", # mandatory
8
+ # :name_identity_format => Samlr::EMAIL_FORMAT,
9
+ # :consumer_service_url => "https://sp.example.org/saml"
10
+ class MetadataBuilder
11
+
12
+ def self.build(options = {})
13
+ name_identity_format = options[:name_identity_format]
14
+ consumer_service_url = options[:consumer_service_url]
15
+ consumer_service_binding = options[:consumer_service_binding] || "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
16
+
17
+ # Mandatory
18
+ entity_id = options.fetch(:entity_id)
19
+
20
+ builder = Nokogiri::XML::Builder.new do |xml|
21
+ xml.EntityDescriptor("xmlns:md" => NS_MAP["md"], "entityID" => entity_id) do
22
+ xml.doc.root.namespace = xml.doc.root.namespace_definitions.find { |ns| ns.prefix == "md" }
23
+
24
+ xml["md"].SPSSODescriptor("protocolSupportEnumeration" => NS_MAP["samlp"]) do
25
+ unless name_identity_format.nil?
26
+ xml["md"].NameIDFormat(name_identity_format)
27
+ end
28
+
29
+ unless consumer_service_url.nil?
30
+ xml["md"].AssertionConsumerService("index" => "0", "Binding" => consumer_service_binding, "Location" => consumer_service_url)
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ builder.to_xml(COMPACT)
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ require "nokogiri"
2
+
3
+ module Samlr
4
+ module Tools
5
+
6
+ # Use this for building the SAML auth request XML
7
+ module RequestBuilder
8
+ def self.build(options = {})
9
+ consumer_service_url = options[:consumer_service_url]
10
+ issuer = options[:issuer]
11
+ name_identity_format = options[:name_identity_format]
12
+ allow_create = options[:allow_create] || "true"
13
+ authn_context = options[:authn_context]
14
+
15
+ builder = Nokogiri::XML::Builder.new do |xml|
16
+ xml.AuthnRequest("xmlns:samlp" => NS_MAP["samlp"], "xmlns:saml" => NS_MAP["saml"], "ID" => Samlr::Tools.uuid, "IssueInstant" => Samlr::Tools::Timestamp.stamp, "Version" => "2.0") do
17
+ xml.doc.root.namespace = xml.doc.root.namespace_definitions.find { |ns| ns.prefix == "samlp" }
18
+
19
+ unless consumer_service_url.nil?
20
+ xml.doc.root["AssertionConsumerServiceURL"] = consumer_service_url
21
+ end
22
+
23
+ unless issuer.nil?
24
+ xml["saml"].Issuer(issuer)
25
+ end
26
+
27
+ unless name_identity_format.nil?
28
+ xml["samlp"].NameIDPolicy("AllowCreate" => allow_create, "Format" => name_identity_format)
29
+ end
30
+
31
+ unless authn_context.nil?
32
+ xml["samlp"].RequestedAuthnContext("Comparison" => "exact") do
33
+ xml["saml"].AuthnContextClassRef(authn_context)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ builder.to_xml(COMPACT)
40
+ end
41
+
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,157 @@
1
+ require "nokogiri"
2
+ require "time"
3
+ require "uuidtools"
4
+
5
+ module Samlr
6
+ module Tools
7
+
8
+ # Use this for building test data, not ready to use for production data
9
+ module ResponseBuilder
10
+
11
+ def self.build(options = {})
12
+ issue_instant = options[:issue_instant] || Samlr::Tools::Timestamp.stamp
13
+ response_id = options[:response_id] || Samlr::Tools.uuid
14
+ assertion_id = options[:assertion_id] || Samlr::Tools.uuid
15
+ status_code = options[:status_code] || "urn:oasis:names:tc:SAML:2.0:status:Success"
16
+ name_id_format = options[:name_id_format] || EMAIL_FORMAT
17
+ subject_conf_m = options[:subject_conf_m] || "urn:oasis:names:tc:SAML:2.0:cm:bearer"
18
+ version = options[:version] || "2.0"
19
+ auth_context = options[:auth_context] || "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
20
+ issuer = options[:issuer] || "ResponseBuilder IdP"
21
+ attributes = options[:attributes] || {}
22
+
23
+ # Mandatory for responses
24
+ destination = options.fetch(:destination)
25
+ in_response_to = options.fetch(:in_response_to)
26
+ name_id = options.fetch(:name_id)
27
+ not_on_or_after = options.fetch(:not_on_or_after)
28
+ not_before = options.fetch(:not_before)
29
+ audience = options.fetch(:audience)
30
+
31
+ # Signature settings
32
+ sign_assertion = [ true, false ].member?(options[:sign_assertion]) ? options[:sign_assertion] : true
33
+ sign_response = [ true, false ].member?(options[:sign_response]) ? options[:sign_response] : true
34
+
35
+ # Fixture controls
36
+ skip_assertion = options[:skip_assertion]
37
+ skip_conditions = options[:skip_conditions]
38
+
39
+ builder = Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml|
40
+ xml.Response("xmlns:samlp" => NS_MAP["samlp"], "ID" => response_id, "InResponseTo" => in_response_to, "Version" => version, "IssueInstant" => issue_instant, "Destination" => destination) do
41
+ xml.doc.root.add_namespace_definition("saml", NS_MAP["saml"])
42
+ xml.doc.root.namespace = xml.doc.root.namespace_definitions.find { |ns| ns.prefix == "samlp" }
43
+
44
+ xml["saml"].Issuer(issuer)
45
+ xml["samlp"].Status { |xml| xml["samlp"].StatusCode("Value" => status_code) }
46
+
47
+ unless skip_assertion
48
+ xml["saml"].Assertion("xmlns:saml" => NS_MAP["saml"], "ID" => assertion_id, "IssueInstant" => issue_instant, "Version" => "2.0") do
49
+ xml["saml"].Issuer(issuer)
50
+
51
+ xml["saml"].Subject do
52
+ xml["saml"].NameID(name_id, "Format" => name_id_format)
53
+
54
+ xml["saml"].SubjectConfirmation("Method" => subject_conf_m) do
55
+ xml["saml"].SubjectConfirmationData("InResponseTo" => in_response_to, "NotOnOrAfter" => not_on_or_after, "Recipient" => destination)
56
+ end
57
+ end
58
+
59
+ unless skip_conditions
60
+ xml["saml"].Conditions("NotBefore" => not_before, "NotOnOrAfter" => not_on_or_after) do
61
+ xml["saml"].AudienceRestriction do
62
+ xml["saml"].Audience(audience)
63
+ end
64
+ end
65
+ end
66
+
67
+ xml["saml"].AuthnStatement("AuthnInstant" => issue_instant, "SessionIndex" => assertion_id) do
68
+ xml["saml"].AuthnContext do
69
+ xml["saml"].AuthnContextClassRef(auth_context)
70
+ end
71
+ end
72
+
73
+ unless attributes.empty?
74
+ xml["saml"].AttributeStatement do
75
+ attributes.keys.sort.each do |name|
76
+ xml["saml"].Attribute("Name" => name) do
77
+ values = Array(attributes[name])
78
+ values.each do |value|
79
+ xml["saml"].AttributeValue(value, "xmlns:xsi" => NS_MAP["xsi"], "xmlns:xs" => NS_MAP["xs"], "xsi:type" => "xs:string")
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # The core response is ready, not on to signing
91
+ response = builder.doc
92
+
93
+ response = sign(response, assertion_id, options) if sign_assertion
94
+ response = sign(response, response_id, options) if sign_response
95
+
96
+ response.to_xml(COMPACT)
97
+ end
98
+
99
+ def self.sign(document, element_id, options)
100
+ certificate = options[:certificate] || Samlr::Tools::CertificateBuilder.new
101
+ element = document.at("//*[@ID='#{element_id}']")
102
+ digest = digest(document, element, options)
103
+ canoned = digest.at("./ds:SignedInfo", NS_MAP).canonicalize(C14N)
104
+ signature = certificate.sign(canoned)
105
+ skip_keyinfo = options[:skip_keyinfo]
106
+
107
+ Nokogiri::XML::Builder.with(digest) do |xml|
108
+ xml.SignatureValue(signature)
109
+ xml.KeyInfo do
110
+ xml.X509Data do
111
+ xml.X509Certificate(certificate.x509_as_pem)
112
+ end
113
+ end unless skip_keyinfo
114
+ end
115
+ # digest.root.last_element_child.after "<SignatureValue>#{signature}</SignatureValue>"
116
+ element.at("./saml:Issuer", NS_MAP).add_next_sibling(digest)
117
+
118
+ document
119
+ end
120
+
121
+ def self.digest(document, element, options)
122
+ c14n_method = options[:c14n_method] || "http://www.w3.org/2001/10/xml-exc-c14n#"
123
+ sign_method = options[:sign_method] || "http://www.w3.org/2000/09/xmldsig#rsa-sha1"
124
+ digest_method = options[:digest_method] || "http://www.w3.org/2000/09/xmldsig#sha1"
125
+ env_signature = options[:env_signature] || "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
126
+ namespaces = options[:namespaces] || [ "#default", "samlp", "saml", "ds", "xs", "xsi" ]
127
+
128
+ canoned = element.canonicalize(C14N, namespaces)
129
+ digest_value = Base64.encode64(OpenSSL::Digest::SHA1.new.digest(canoned)).delete("\n")
130
+
131
+ builder = Nokogiri::XML::Builder.new(:encoding => "UTF-8") do |xml|
132
+ xml.Signature("xmlns" => NS_MAP["ds"]) do
133
+
134
+ xml.SignedInfo do
135
+ xml.CanonicalizationMethod("Algorithm" => c14n_method)
136
+ xml.SignatureMethod("Algorithm" => sign_method)
137
+
138
+ xml.Reference("URI" => "##{element['ID']}") do
139
+ xml.Transforms do
140
+ xml.Transform("Algorithm" => env_signature)
141
+ xml.Transform("Algorithm" => c14n_method) do
142
+ xml.InclusiveNamespaces("xmlns" => c14n_method, "PrefixList" => namespaces.join(" "))
143
+ end
144
+ end
145
+ xml.DigestMethod("Algorithm" => digest_method)
146
+ xml.DigestValue(digest_value)
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ builder.doc.root
153
+ end
154
+
155
+ end
156
+ end
157
+ end