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,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
|