google-sso 1.0.2

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.
@@ -0,0 +1,10 @@
1
+ Manifest.txt
2
+ README.txt
3
+ Rakefile
4
+ lib/SamlResponseTemplate.xml
5
+ lib/SignatureTemplate.xml
6
+ lib/google-sso.rb
7
+ lib/google-sso/version.rb
8
+ lib/processresponse.rb
9
+ lib/xmlcanonicalizer.rb
10
+ test/test_GoogleSSO.rb
@@ -0,0 +1,41 @@
1
+ Google SSO gem supported by ELC Technologies. Read more about our Ruby on Rails development services and commitment to the Rails community at elctech.com
2
+
3
+ GoogleSSO
4
+ by Thomas Ormerod
5
+
6
+ == DESCRIPTION:
7
+
8
+ Provides single-sign-on to Google premier services.
9
+
10
+ == FEATURES/PROBLEMS:
11
+
12
+ Uses an incomplete xml canonicalizer.
13
+
14
+ == REQUIREMENTS:
15
+
16
+ Core classes only
17
+
18
+ == INSTALL:
19
+
20
+ sudo gem install google-sso
21
+
22
+ == USAGE:
23
+
24
+ 1. Generate an RSA key (DSA has issues with ruby/openssl)
25
+ 1. openssl genrsa -out privkey.pem [strength ie. 1024, 2048]
26
+ 2. Create a self-signed certificate (or signed your choice)
27
+ 1. openssl req -new -x509 -key privkey.pem -out cacert.pem [optional -days ie. 1095]
28
+ 3. Go to https://www.google.com/a/YOUR-DOMAIN and login as administrator
29
+ 1. Advanced tools > Single sign-on
30
+ 2. Enable Single Sign-on
31
+ 3. Set your Sign-in page URL to your sites login page
32
+ 4. Set your other URL's
33
+ 5. Save
34
+ 6. Upload your certificate (cacert.pem)
35
+ 7. Logout
36
+ 8. You have now enabled single sign on.
37
+
38
+
39
+ Anytime a user attempts to access a Google service with /a/[your domain]
40
+ on the end they will first have to authenticate through you, unless they
41
+ previously authenticated and a cookie is set.
@@ -0,0 +1,17 @@
1
+ require 'rubygems'
2
+ require 'hoe'
3
+ $:.unshift File.join(File.dirname(__FILE__), 'lib')
4
+ # Gem::manage_gems
5
+ # require 'rake/gempackagetask'
6
+ require 'google-sso'
7
+
8
+ Hoe.new('GoogleSSO', GoogleSSO::VERSION::STRING) do |p|
9
+ p.rubyforge_name = 'google-sso'
10
+ p.name = 'google-sso'
11
+ p.author = 'Thomas Ormerod, David Palm, and Ryan Garver'
12
+ p.description = 'Google Apps Single Sign-on (SSO) support.'
13
+ p.email = 'google-sso@elctech.com'
14
+ p.summary = 'Google Apps Single Sign-on'
15
+ p.url = 'http://elctech.com'
16
+ p.rdoc_pattern = /\.txt$|lib\/.*\.rb$/
17
+ end
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?><samlp:Response ID="<RESPONSE_ID>" IssueInstant="<ISSUE_INSTANT>" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#"><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status><Assertion ID="<ASSERTION_ID>" IssueInstant="<ISSUE_INSTANT>" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion"><Issuer>https://www.opensaml.org/IDP</Issuer><Subject><NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress"><USERNAME_STRING></NameID><SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"/></Subject><Conditions NotBefore="<NOT_BEFORE>" NotOnOrAfter="<NOT_ON_OR_AFTER>"/><AuthnStatement AuthnInstant="<AUTHN_INSTANT>"><AuthnContext><AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</AuthnContextClassRef></AuthnContext></AuthnStatement></Assertion></samlp:Response>
@@ -0,0 +1,19 @@
1
+ <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
2
+ <SignedInfo>
3
+ <CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
4
+ <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>
5
+ <Reference URI="">
6
+ <Transforms>
7
+ <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
8
+ </Transforms>
9
+ <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
10
+ <DigestValue></DigestValue>
11
+ </Reference>
12
+ </SignedInfo>
13
+ <SignatureValue></SignatureValue>
14
+ <KeyInfo>
15
+ <X509Data>
16
+ <X509Certificate></X509Certificate>
17
+ </X509Data>
18
+ </KeyInfo>
19
+ </Signature>
@@ -0,0 +1,3 @@
1
+ require 'xmlcanonicalizer'
2
+ require 'processresponse'
3
+ require 'google-sso/version'
@@ -0,0 +1,9 @@
1
+ module GoogleSSO #:nodoc:
2
+ module VERSION #:nodoc:
3
+ MAJOR = 1
4
+ MINOR = 0
5
+ TINY = 2
6
+
7
+ STRING = [MAJOR, MINOR, TINY].join('.')
8
+ end
9
+ end
@@ -0,0 +1,221 @@
1
+ require "openssl"
2
+ require "openssl/x509"
3
+ require "base64"
4
+ require "rexml/document"
5
+ require "rexml/xpath"
6
+ require "zlib"
7
+ require "digest/sha1"
8
+ require "xmlcanonicalizer"
9
+ require 'log4r'
10
+
11
+ include Log4r
12
+ include REXML
13
+ include OpenSSL
14
+
15
+ class XmlProcessResponse
16
+ attr_reader :acs, :signed_response, :acs_form
17
+
18
+ # Create an XmlProcessResponse object
19
+ #
20
+ # certificate_path: path to the site certificate, previously uploaded to Google using the SSO admin panel
21
+ # private_key_path: path to the private key that will be used to sign the SAML Response
22
+ def initialize(certificate_path, private_key_path)
23
+ @certificate_path = certificate_path
24
+ @private_key_path = private_key_path
25
+
26
+ libpath = "#{Gem.dir}/gems/google-sso-#{GoogleSSO::VERSION::STRING}/lib" # FIXME: uses hardcoded gemname. Bad bad bad.
27
+ @signature_template_path = "#{libpath}/SignatureTemplate.xml"
28
+ @response_template_path = "#{libpath}/SamlResponseTemplate.xml"
29
+
30
+ @issue_instant = ""
31
+ @provider_name = ""
32
+
33
+ @acs = ""
34
+ @acs_form = ""
35
+
36
+ @signed_response = ""
37
+
38
+ @logger = Logger.new("response.log")
39
+ @logger.level = Logger::DEBUG
40
+ end
41
+
42
+
43
+ # Builds a response SAML document to send back to Google.
44
+ #
45
+ # Takes three parameters: a SAMLRequest-document, the RelayState
46
+ # variable and the username to authenticate.
47
+ # samlRequest: a string containing the SAML request Google sends out when a user tries to login to a SSO enabled domain.
48
+ # relayState: a string containing various state parameters regarding the Google domain and the service required.
49
+ # username: a string with the username that needs to be authenticated.
50
+ #
51
+ # The method returns an HTML snippet containing a form with two
52
+ # <tt><textarea></tt> elements with the SAML response signed using the key, and
53
+ # the RelayState and the form action set to the Google Assertion Consumer Service URL.
54
+ # Both the SAMLResponse and the RelayState parameters has to be Base64 encoded when submited to Google; this is automatically
55
+ # handled for text inserted into <tt>textarea</tt> elements.
56
+ #
57
+ # The form should be inserted in the resulting page and submit()-ed by a onload javascript function such as:
58
+ # window.onload = function(){
59
+ # var f = document.getElementById('acsForm');
60
+ # if(f.nodeName=='FORM'){
61
+ # f.submit();
62
+ # }
63
+ # }
64
+ def process_response(raw_saml_request, relay_state, username)
65
+ saml_request = decode_authn_request(raw_saml_request)
66
+ get_request_attributes(saml_request)
67
+
68
+ saml_response = create_saml_response(username)
69
+
70
+ @logger.debug("\nSAML Response\n" + saml_response.to_s()) if @logger
71
+
72
+ @signed_response = sign_XML(saml_response)
73
+
74
+ @logger.debug("\nSigned Response\n" + signed_response.to_s()) if @logger
75
+
76
+ @acs_form = <<-ACSFORM
77
+ <form name="acsForm" id="acsForm" action="#{@acs}" method="post">
78
+ <textarea name="SAMLResponse">#{signed_response}</textarea>
79
+ <textarea name="RelayState">#{relay_state}</textarea>
80
+ </form>
81
+ ACSFORM
82
+ end
83
+
84
+ private
85
+
86
+ # The SAMLRequest is zipped and Base64 encoded. This method unzips and decodes the request.
87
+ def decode_authn_request(encoded_saml_request) #:doc:
88
+ unzipper = Zlib::Inflate.new( -Zlib::MAX_WBITS )
89
+ unzipper.inflate(Base64.decode64(encoded_saml_request))
90
+ end
91
+
92
+ # Extracts three attributes from the SAMLRequest sent by Google and sets the corresponding ivars:
93
+ # - issueinstant: when was the request issued (resent in the SAMLResponse in order to establish validity in time of the auth request/response to assure the response cannot be used before the request was issued)
94
+ # - providername: this is the name of the service provider requesting authentication from us.
95
+ # - acs: the URL at the service provider site to which our SAMLResponse doc should be sent.
96
+ #
97
+ # Takes an XML string (the SAML Request) in input.
98
+ def get_request_attributes(xmlString) #:doc:
99
+ doc = Document.new( xmlString )
100
+ @issue_instant = doc.root.attributes["IssueInstant"]
101
+ @provider_name = doc.root.attributes["ProviderName"] # FIXME: This is not really used anywhere; is set to "google.com". Should we check for this? Or remove the ivar?
102
+ @acs = doc.root.attributes["AssertionConsumerServiceURL"]
103
+ end
104
+
105
+ # Create the SAMLResponse
106
+ #
107
+ # Loads the template and substitutes the markers USERNAME_STRING, RESPONSE_ID, ISSUE_INSTANT, AUTHN_INSTANT, NOT_BEFORE, NOT_ON_OR_AFTER and ASSERTION_ID.
108
+ #
109
+ # Takes a string in input with the username of the user requesting authentication.
110
+ # The SAMLResponse is valid for 20 minutes.
111
+ def create_saml_response(username) #:doc:
112
+ current_time = Time.new().utc().strftime("%Y-%m-%dT%H:%M:%SZ")
113
+ # 20 minutes after issued time
114
+ not_on_or_after = (Time.new().utc()+60*20).strftime("%Y-%m-%dT%H:%M:%SZ")
115
+
116
+ saml_response = File.read @response_template_path
117
+
118
+ saml_response.sub!("<USERNAME_STRING>", username)
119
+ saml_response.sub!("<RESPONSE_ID>", generate_unique_hexcode(42))
120
+ saml_response.gsub!("<ISSUE_INSTANT>", current_time)
121
+ saml_response.sub!("<AUTHN_INSTANT>", current_time)
122
+ saml_response.sub!("<NOT_BEFORE>", @issue_instant)
123
+ saml_response.sub!("<NOT_ON_OR_AFTER>", not_on_or_after)
124
+ saml_response.sub!("<ASSERTION_ID>", generate_unique_hexcode(42))
125
+
126
+ return saml_response
127
+ end
128
+
129
+ # Takes an XML string built from the SAMLResponse template and inserts a digest, signature and certificate into the SignatureTemplate. Returns a string rappresentation of the XML document.
130
+ def sign_XML(saml_response) #:doc:
131
+ signature = File.read @signature_template_path
132
+
133
+ saml_response_doc = Document.new(saml_response)
134
+ sig_doc = Document.new(signature)
135
+
136
+ # 3. Apply digesting algorithms over the resource, and calculate the digest value.
137
+ digest_value = calculate_digest(saml_response_doc)
138
+
139
+ # 4. Enclose the details in the <Reference> element.
140
+ # 5. Collect all <Reference> elements inside the <SignedInfo> element. Indicate the canonicalization and signature methodologies.
141
+ digest_element = XPath.first( sig_doc, "//DigestValue" )
142
+ digest_element.add_text( digest_value )
143
+
144
+ # 6. Canonicalize contents of <SignedInfo>, apply the signature algorithm, and generate the XML Digital signature.
145
+ signed_element = XPath.first( sig_doc, "//SignedInfo" )
146
+ signature_value = calculate_signature_value( signed_element )
147
+
148
+ # 7. Enclose the signature within the <SignatureValue> element.
149
+ signature_value_element = XPath.first( sig_doc, "//SignatureValue" )
150
+ signature_value_element.add_text( signature_value )
151
+
152
+ # 8. Add relevant key information, if any, and produce the <Signature> element.
153
+ cert = File.read @certificate_path
154
+
155
+ cert.sub!(/.*BEGIN CERTIFICATE-----\s*(.*)\s*-----END CERT.*/m, '\1')
156
+
157
+ cert_node = XPath.first(sig_doc, "//X509Certificate")
158
+ cert_node.add_text(cert)
159
+
160
+ # 9. put it all together
161
+ status_child = saml_response_doc.elements["//samlp:Status"]
162
+ status_child.parent.insert_before(status_child, sig_doc.root)
163
+ retval = saml_response_doc.to_s()
164
+
165
+ return retval
166
+ end
167
+
168
+ # Builds a canonicalized version of the passed DOM element, signs it and returns a Base64-encoded version.
169
+ # The element is temporarily provided with the following namespaces:
170
+ # - http://www.w3.org/2000/09/xmldsig#
171
+ # - urn:oasis:names:tc:SAML:2.0:protocol
172
+ # - http://www.w3.org/2001/04/xmlenc#
173
+ #
174
+ def calculate_signature_value(element)
175
+ element.add_namespace "http://www.w3.org/2000/09/xmldsig#"
176
+ element.add_namespace "xmlns:samlp", "urn:oasis:names:tc:SAML:2.0:protocol"
177
+ element.add_namespace "xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#"
178
+
179
+ canoner = XmlCanonicalizer.new(false, true)
180
+ canon_element = canoner.canonicalize( element )
181
+
182
+ pkey = PKey::RSA.new( File::read( @private_key_path ) )
183
+ signature = Base64.encode64( pkey.sign( OpenSSL::Digest::SHA1.new, canon_element.to_s.chomp ).chomp ).chomp
184
+
185
+ element.delete_attribute "xmlns"
186
+ element.delete_attribute "xmlns:samlp"
187
+ element.delete_attribute "xmlns:xenc"
188
+ return signature
189
+ end
190
+
191
+ # Builds a canonicalized version of the passed DOM element and returns a
192
+ # SHA1-digest of the element (Base64 encoded string).
193
+ def calculate_digest(element) #:doc:
194
+ canoner = XmlCanonicalizer.new(false, true)
195
+ canon_element = canoner.canonicalize( element )
196
+ Base64.encode64( Digest::SHA1.digest( canon_element.to_s.chomp ).chomp ).chomp
197
+ end
198
+
199
+ # Generate an ID string for use in two attributes in the SAMLResponse
200
+ # (response ID and assertion ID); these has to obey specific rules
201
+ # (see Google SSO documentation).
202
+ #
203
+ # Returns a random hexadecimal string <tt>code_length</tt> long.
204
+ def generate_unique_hexcode( code_length ) #:doc:
205
+ valid_chars = ("A".."F").to_a + ("0".."9").to_a
206
+ length = valid_chars.size
207
+
208
+ valid_start_chars = ("A".."F").to_a
209
+ start_length = valid_start_chars.size
210
+
211
+ hexcode = ""
212
+ hexcode << valid_start_chars[rand(start_length-1)]
213
+
214
+ 1.upto(code_length-1){|i|
215
+ hexcode << valid_chars[rand(length-1)]
216
+ }
217
+
218
+ return hexcode
219
+ end
220
+
221
+ end
@@ -0,0 +1,405 @@
1
+ require "rexml/document"
2
+ require "base64"
3
+
4
+ include REXML
5
+
6
+
7
+ class REXML::Instruction
8
+ def write(writer, indent=-1, transitive=false, ie_hack=false)
9
+ indent(writer, indent)
10
+ writer << START.sub(/\\/u, '')
11
+ writer << @target
12
+ writer << ' '
13
+ writer << @content if @content != nil
14
+ writer << STOP.sub(/\\/u, '')
15
+ end
16
+ end
17
+
18
+ class REXML::Attribute
19
+ def <=>(a2)
20
+ if (self === a2)
21
+ return 0
22
+ elsif (self == nil)
23
+ return -1
24
+ elsif (a2 == nil)
25
+ return 1
26
+ elsif (self.prefix() == a2.prefix())
27
+ return self.name()<=>a2.name()
28
+ end
29
+ if (self.prefix() == nil)
30
+ return -1
31
+ elsif (a2.prefix() == nil)
32
+ return 1
33
+ end
34
+ ret = self.namespace()<=>a2.namespace()
35
+ if (ret == 0)
36
+ ret = self.prefix()<=>a2.prefix()
37
+ end
38
+ return ret
39
+ end
40
+ end
41
+
42
+ class REXML::Element
43
+ def search_namespace(prefix)
44
+ if (self.namespace(prefix) == nil)
45
+ return (self.parent().search_namespace(prefix)) if (self.parent() != nil)
46
+ else
47
+ return self.namespace(prefix)
48
+ end
49
+ end
50
+ def rendered=(rendered)
51
+ @rendered = rendered
52
+ end
53
+ def rendered?()
54
+ return @rendered
55
+ end
56
+ def node_namespaces()
57
+ ns = Array.new()
58
+ ns.push(self.prefix())
59
+ self.attributes().each_attribute{|a|
60
+ if (a.prefix() == "xmlns" or (a.prefix() == "" && a.local_name() == "xmlns"))
61
+ ns.push("xmlns")
62
+ end
63
+ }
64
+ self.prefixes().each { |prefix| ns.push(prefix) }
65
+ ns
66
+ end
67
+ end
68
+
69
+ class NamespaceNode
70
+ attr_reader :prefix, :uri
71
+ def initialize(prefix, uri)
72
+ @prefix = prefix
73
+ @uri = uri
74
+ end
75
+ end
76
+
77
+ # From the IBM DeveloperWorks website[http://www.ibm.com/developerworks/xml/library/x-c14n/]:
78
+ #
79
+ # <em>XML is careful to separate details of a file or other data source, bit-by-bit, from the
80
+ # abstract model of an XML document. This can be an inconvenience when comparing two
81
+ # XML documents for equality -- either directly (for instance, as part of a test suite)
82
+ # or by comparing digital signatures for security purposes -- to determine whether an
83
+ # XML document has been tampered with in some way. The W3C addresses this problem with the
84
+ # XML Canonicalization spec (c14n), which defines a standard form for an XML document that
85
+ # is guaranteed to provide proper bit-wise comparisons and thus consistent digital signatures.</em>
86
+
87
+ class XmlCanonicalizer
88
+ attr_accessor :prefix_list, :logger
89
+
90
+ BEFORE_DOC_ELEMENT = 0
91
+ INSIDE_DOC_ELEMENT = 1
92
+ AFTER_DOC_ELEMENT = 2
93
+
94
+ NODE_TYPE_ATTRIBUTE = 3
95
+ NODE_TYPE_WHITESPACE = 4
96
+ NODE_TYPE_COMMENT = 5
97
+ NODE_TYPE_PI = 6
98
+ NODE_TYPE_TEXT = 7
99
+
100
+
101
+ def initialize(with_comments, excl_c14n)
102
+ @with_comments = with_comments
103
+ @exclusive = excl_c14n
104
+ @res = ""
105
+ @state = BEFORE_DOC_ELEMENT
106
+ @xnl = Array.new()
107
+ @prevVisibleNamespacesStart = 0
108
+ @prevVisibleNamespacesEnd = 0
109
+ @visibleNamespaces = Array.new()
110
+ @inclusive_namespaces = Array.new()
111
+ @prefix_list = nil
112
+ @rendered_prefixes = Array.new()
113
+ @logger = Logger.new("xmlcanonicalizer.log")
114
+ @logger.level = Logger::DEBUG
115
+ end
116
+
117
+ def add_inclusive_namespaces(prefix_list, element, visible_namespaces)
118
+ namespaces = element.attributes()
119
+ namespaces.each_attribute{|ns|
120
+ if (ns.prefix=="xmlns")
121
+ if (prefix_list.include?(ns.local_name()))
122
+ visible_namespaces.push(NamespaceNode.new("xmlns:"+ns.local_name(), ns.value()))
123
+ end
124
+ end
125
+ }
126
+ parent = element.parent()
127
+ add_inclusive_namespaces(prefix_list, parent, visible_namespaces) if (parent)
128
+ visible_namespaces
129
+ end
130
+
131
+ def canonicalize(document)
132
+ write_document_node(document)
133
+ @logger.debug("\nCanonicalized result\n" + @res.to_s()) if @logger
134
+ @res
135
+ end
136
+
137
+ def canonicalize_element(element, logging = true)
138
+ @logger.debug("Canonicalize element:\n" + element.to_s()) if @logger
139
+ @inclusive_namespaces = add_inclusive_namespaces(@prefix_list, element, @inclusive_namespaces) if (@prefix_list)
140
+ @preserve_document = element.document()
141
+ tmp_parent = element.parent()
142
+ body_string = remove_whitespace(element.to_s().gsub("\n","").gsub("\t","").gsub("\r",""))
143
+ document = Document.new(body_string)
144
+ tmp_parent.delete_element(element)
145
+ element = tmp_parent.add_element(document.root())
146
+ @preserve_element = element
147
+ document = Document.new(element.to_s())
148
+ ns = element.namespace(element.prefix())
149
+ document.root().add_namespace(element.prefix(), ns)
150
+ write_document_node(document)
151
+ @logger.debug("Canonicalized result:\n" + @res.to_s()) if @logger
152
+ @res
153
+ end
154
+
155
+ def write_document_node(document)
156
+ @state = BEFORE_DOC_ELEMENT
157
+ if (document.class().to_s() == "REXML::Element")
158
+ write_node(document)
159
+ else
160
+ document.each_child{|child|
161
+ write_node(child)
162
+ }
163
+ end
164
+ @res
165
+ end
166
+
167
+ def write_node(node)
168
+ visible = is_node_visible(node)
169
+ if ((node.node_type() == :text) && white_text?(node.value()))
170
+ res = node.value()
171
+ res.gsub("\r\n","\n")
172
+ #res = res.delete(" ").delete("\t")
173
+ res.delete("\r")
174
+ @res = @res + res
175
+ #write_text_node(node,visible) if (@state == INSIDE_DOC_ELEMENT)
176
+ return
177
+ end
178
+ if (node.node_type() == :text)
179
+ write_text_node(node, visible)
180
+ return
181
+ end
182
+ if (node.node_type() == :element)
183
+ write_element_node(node, visible) if (!node.rendered?())
184
+ node.rendered=(true)
185
+ end
186
+ if (node.node_type() == :processing_instruction)
187
+ end
188
+ if (node.node_type() == :comment)
189
+ end
190
+ end
191
+
192
+ def write_element_node(node, visible)
193
+ savedPrevVisibleNamespacesStart = @prevVisibleNamespacesStart
194
+ savedPrevVisibleNamespacesEnd = @prevVisibleNamespacesEnd
195
+ savedVisibleNamespacesSize = @visibleNamespaces.size()
196
+ state = @state
197
+ state = INSIDE_DOC_ELEMENT if (visible && state == BEFORE_DOC_ELEMENT)
198
+ @res = @res + "<" + node.expanded_name() if (visible)
199
+ write_namespace_axis(node, visible)
200
+ write_attribute_axis(node)
201
+ @res = @res + ">" if (visible)
202
+ node.each_child{|child|
203
+ write_node(child)
204
+ }
205
+ @res = @res + "</" +node.expanded_name() + ">" if (visible)
206
+ @state = AFTER_DOC_ELEMENT if (visible && state == BEFORE_DOC_ELEMENT)
207
+ @prevVisibleNamespacesStart = savedPrevVisibleNamespacesStart
208
+ @prevVisibleNamespacesEnd = savedPrevVisibleNamespacesEnd
209
+ @visibleNamespaces.slice!(savedVisibleNamespacesSize, @visibleNamespaces.size() - savedVisibleNamespacesSize) if (@visibleNamespaces.size() > savedVisibleNamespacesSize)
210
+ end
211
+
212
+ def write_namespace_axis(node, visible)
213
+ doc = node.document()
214
+ has_empty_namespace = false
215
+ list = Array.new()
216
+ cur = node
217
+ #while ((cur != nil) && (cur != doc) && (cur.node_type() != :document))
218
+ namespaces = cur.node_namespaces()
219
+ namespaces.each{|prefix|
220
+ next if ((prefix == "xmlns") && (node.namespace(prefix) == ""))
221
+ namespace = cur.namespace(prefix)
222
+ next if (is_namespace_node(namespace))
223
+ next if (node.namespace(prefix) != cur.namespace(prefix))
224
+ next if (prefix == "xml" && namespace == "http://www.w3.org/XML/1998/namespace")
225
+ next if (!is_node_visible(cur))
226
+ rendered = is_namespace_rendered(prefix, namespace)
227
+ @visibleNamespaces.push(NamespaceNode.new("xmlns:"+prefix,namespace)) if (visible)
228
+ if ((!rendered) && !list.include?(prefix))
229
+ list.push(prefix)
230
+ end
231
+ has_empty_namespace = true if (prefix == nil)
232
+ }
233
+ if (visible && !has_empty_namespace && !is_namespace_rendered(nil, nil))
234
+ @res = @res + ' xmlns=""'
235
+ end
236
+ #TODO: ns of inclusive_list
237
+ #=begin
238
+ if ((@prefix_list) && (node.to_s() == node.parent().to_s()))
239
+ #list.push(node.prefix())
240
+ @inclusive_namespaces.each{|ns|
241
+ prefix = ns.prefix().split(":")[1]
242
+ list.push(prefix) if (!list.include?(prefix) && (!node.attributes.prefixes.include?(prefix)))
243
+ }
244
+ @prefix_list = nil
245
+ end
246
+ #=end
247
+ list.sort!()
248
+ list.insert(0, "xmlns") unless list.delete("xmlns").nil?
249
+ list.each{|prefix|
250
+ next if (prefix == "")
251
+ next if (@rendered_prefixes.include?(prefix))
252
+ @rendered_prefixes.push(prefix)
253
+ ns = node.namespace(prefix)
254
+ ns = @preserve_element.namespace(prefix) if (ns == nil)
255
+ @res = @res + normalize_string(" " + prefix + '="' + ns + '"', NODE_TYPE_TEXT) if (prefix == "xmlns")
256
+ @res = @res + normalize_string(" xmlns:" + prefix + '="' + ns + '"', NODE_TYPE_TEXT) if (prefix != nil && prefix != "xmlns")
257
+ }
258
+ if (visible)
259
+ @prevVisibleNamespacesStart = @prevVisibleNamespacesEnd
260
+ @prevVisibleNamespacesEnd = @visibleNamespaces.size()
261
+ end
262
+ end
263
+
264
+ def write_attribute_axis(node)
265
+ list = Array.new()
266
+ node.attributes().sort.each{|key, attr|
267
+ list.push(attr) if (!is_namespace_node(attr.value()) && !is_namespace_decl(attr)) # && is_node_visible(
268
+ }
269
+ if (!@exclusive && node.parent() != nil && node.parent().parent() != nil)
270
+ cur = node.parent()
271
+ while (cur != nil)
272
+ #next if (cur.attributes() == nil)
273
+ cur.each_attribute{|attribute|
274
+ next if (attribute.prefix() != "xml")
275
+ next if (attribute.prefix().index("xmlns") == 0)
276
+ next if (node.namespace(attribute.prefix()) == attribute.value())
277
+ found = true
278
+ list.each{|n|
279
+ if (n.prefix() == "xml" && n.value() == attritbute.value())
280
+ found = true
281
+ break
282
+ end
283
+ }
284
+ next if (found)
285
+ list.push(attribute)
286
+ }
287
+ end
288
+ end
289
+ list.each{|attribute|
290
+ if (attribute != nil)
291
+ if (attribute.name() != "xmlns")
292
+ @res = @res + " " + normalize_string(attribute.to_string(), NODE_TYPE_ATTRIBUTE).gsub("'",'"')
293
+ end
294
+ # else
295
+ # @res = @res + " " + normalize_string(attribute.name()+'="'+attribute.to_s()+'"', NODE_TYPE_ATTRIBUTE).gsub("'",'"')
296
+ #end
297
+ end
298
+ }
299
+ end
300
+
301
+ def is_namespace_node(namespace_uri)
302
+ return (namespace_uri == "http://www.w3.org/2000/xmlns/")
303
+ end
304
+
305
+ def is_namespace_rendered(prefix, uri)
306
+ is_empty_ns = prefix == nil && uri == nil
307
+ if (is_empty_ns)
308
+ start = 0
309
+ else
310
+ start = @prevVisibleNamespacesStart
311
+ end
312
+ @visibleNamespaces.each{|ns|
313
+ if (ns.prefix() == "xmlns:"+prefix.to_s() && ns.uri() == uri)
314
+ return true
315
+ end
316
+ }
317
+ return is_empty_ns
318
+ #(@visibleNamespaces.size()-1).downto(start) {|i|
319
+ # ns = @visibleNamespaces[i]
320
+ # return true if (ns.prefix() == "xmlns:"+prefix.to_s() && ns.uri() == uri)
321
+ # #p = ns.prefix() if (ns.prefix().index("xmlns") == 0)
322
+ # #return ns.uri() == uri if (p == prefix)
323
+ #}
324
+ #return is_empty_ns
325
+ end
326
+
327
+ def is_node_visible(node)
328
+ return true if (@xnl.size() == 0)
329
+ @xnl.each{|element|
330
+ return true if (element == node)
331
+ }
332
+ return false
333
+ end
334
+
335
+ def normalize_string(input, type)
336
+ sb = ""
337
+ return input
338
+ end
339
+ #input.each_byte{|b|
340
+ # if (b ==60 && (type == NODE_TYPE_ATTRIBUTE || is_text_node(type)))
341
+ # sb = sb + "&lt;"
342
+ # elsif (b == 62 && is_text_node(type))
343
+ # sb = sb + "&gt;"
344
+ # elsif (b == 38 && (is_text_node(type) || is_text_node(type))) #Ampersand
345
+ # sb = sb + "&amp;"
346
+ # elsif (b == 34 && is_text_node(type)) #Quote
347
+ # sb = sb + "&quot;"
348
+ # elsif (b == 9 && is_text_node(type)) #Tabulator
349
+ # sb = sb + "&#x9;"
350
+ # elsif (b == 11 && is_text_node(type)) #CR
351
+ # sb = sb + "&#xA;"
352
+ # elsif (b == 13 && (type == NODE_TYPE_ATTRIBUTE || (is_text_node(type) && type != NODE_TYPE_WHITESPACE) || type == NODE_TYPE_COMMENT || type == NODE_TYPE_PI))
353
+ # sb = sb + "&#xD;"
354
+ # elsif (b == 13)
355
+ # next
356
+ # else
357
+ # sb = sb.concat(b)
358
+ # end
359
+ #}
360
+ #sb
361
+ #end
362
+
363
+ def write_text_node(node, visible)
364
+ if (visible)
365
+ @res = @res + normalize_string(node.value(), node.node_type())
366
+ end
367
+ end
368
+
369
+ def white_text?(text)
370
+ return true if ((text.strip() == "") || (text.strip() == nil))
371
+ return false
372
+ end
373
+
374
+ def is_namespace_decl(attribute)
375
+ #return true if (attribute.name() == "xmlns")
376
+ return true if (attribute.prefix().index("xmlns") == 0)
377
+ return false
378
+ end
379
+
380
+ def is_text_node(type)
381
+ return true if (type == NODE_TYPE_TEXT || type == NODE_TYPE_CDATA || type == NODE_TYPE_WHITESPACE)
382
+ return false
383
+ end
384
+
385
+ def remove_whitespace(string)
386
+ new_string = ""
387
+ in_white = false
388
+ string.each_byte{|b|
389
+ #if (in_white && b == 32)
390
+ #else
391
+ if !(in_white && b == 32)
392
+ new_string = new_string + b.chr()
393
+ end
394
+ if (b == 62) #>
395
+ in_white = true
396
+ end
397
+ if (b == 60) #<
398
+ in_white = false
399
+ end
400
+ }
401
+ new_string
402
+ end
403
+ end
404
+
405
+
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'google-sso'
4
+ require 'test/unit'
5
+
6
+ class TestSSO < Test::Unit::TestCase
7
+ RelayState = 'https://www.google.com/a/davidpalm.net/ServiceLogin?service=mail&passive=true&rm=false&continue=http%3A%2F%2Fmail.google.com%2Fa%2Fdavidpalm.net&ltmpl=default&ltmplcache=2'
8
+ SAMLRequest_encoded = 'fVJLT8MwDL4j8R+i3NeuIAREa6cBQkziUW2FA7cscduMNC5xusG/p+tAwAGknJzP38P2ZPrWWLYBTwZdypNozBk4hdq4KuWPxfXojE+zw4MJyca2YtaF2i3gtQMKrO90JIaPlHfeCZRkSDjZAImgxHJ2dyuOorFoPQZUaDmbX6X8RSqrDSpnpUZZrY0tS21rXFftql6bF12ZtqwVcvb0ZetoZ2tO1MHcUZAu9KXx+HQ0vCJJxPGJOD5/5iz/VLowbp/gP1urPYjETVHko/xhWQwEG6PB3/folFeIlYVIYcPZjAh86O1coqOuAb8EvzEKHhe3Ka9DaEnE8Xa7jb6bYhlr2dO10jaRgxBLRbscuSQym56/lJaAZ8NwxZDP/5jq/+7llx+efStO4h9U2efSdlnmVzlao97ZzFrcXnqQodcPvgPOrtE3MvytlkTJUDF6VA5Q0TlqQZnSgOYszvaqv6+jv5kP'
9
+ SAMLRequest_decoded = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n<samlp:AuthnRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"kacldiocnladoagjilffdlhojgpbhjikdgipfhco\" Version=\"2.0\" IssueInstant=\"2007-07-07T11:35:39Z\" ProtocolBinding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" ProviderName=\"google.com\" AssertionConsumerServiceURL=\"https://www.google.com/a/davidpalm.net/acs\" IsPassive=\"false\"><saml:Issuer xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\">google.com</saml:Issuer><samlp:NameIDPolicy AllowCreate=\"true\" Format=\"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\" /></samlp:AuthnRequest>\r\n"
10
+
11
+ SAMLResponse = '<?xml version=\"1.0\" encoding=\"UTF-8\"?><samlp:Response ID=\"E4B3A6F788CA08C40ED6DC62B8B1A1CF026C05CA51\" IssueInstant=\"2007-07-07T13:54:39Z\" Version=\"2.0\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\" xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:xenc=\"http://www.w3.org/2001/04/xmlenc#\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status><Assertion ID=\"CB811AFE287D86586677DF7B7BCA7F278840330164\" IssueInstant=\"2007-07-07T13:54:39Z\" Version=\"2.0\" xmlns=\"urn:oasis:names:tc:SAML:2.0:assertion\"><Issuer>https://www.opensaml.org/IDP</Issuer><Subject><NameID Format=\"urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress\">sso</NameID><SubjectConfirmation Method=\"urn:oasis:names:tc:SAML:2.0:cm:bearer\"/></Subject><Conditions NotBefore=\"2007-07-07T11:35:39Z\" NotOnOrAfter=\"2007-07-07T14:14:39Z\"/><AuthnStatement AuthnInstant=\"2007-07-07T13:54:39Z\"><AuthnContext><AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</AuthnContextClassRef></AuthnContext></AuthnStatement></Assertion></samlp:Response>'
12
+
13
+ SAMLResponse_re = /<\?xml version=\"1\.0\" encoding=\"UTF-8\"\?><samlp:Response ID=\"[A-Z]{1}[A-Z0-9]{41}\" IssueInstant=\"[\d]{4}-[\d]{2}-[\d]{2}[A-Z]{1}[\d]{2}:[\d]{2}:[\d]{2}[A-Z]{1}\" Version=\"2\.0\" xmlns=\"urn:oasis:names:tc:SAML:2\.0:assertion\" xmlns:samlp=\"urn:oasis:names:tc:SAML:2\.0:protocol\" xmlns:xenc=\"http:\/\/www\.w3\.org\/2001\/04\/xmlenc#\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2\.0:status:Success\"\/><\/samlp:Status><Assertion ID=\"[A-Z]{1}[A-Z0-9]{41}\" IssueInstant=\"[\d]{4}-[\d]{2}-[\d]{2}[A-Z]{1}[\d]{2}:[\d]{2}:[\d]{2}[A-Z]{1}\" Version=\"2\.0\" xmlns=\"urn:oasis:names:tc:SAML:2\.0:assertion\"><Issuer>https:\/\/www\.opensaml\.org\/IDP<\/Issuer><Subject><NameID Format=\"urn:oasis:names:tc:SAML:2\.0:nameid-format:emailAddress\">sso<\/NameID><SubjectConfirmation Method=\"urn:oasis:names:tc:SAML:2\.0:cm:bearer\"\/><\/Subject><Conditions NotBefore=\"\" NotOnOrAfter=\"[\d]{4}-[\d]{2}-[\d]{2}[A-Z]{1}[\d]{2}:[\d]{2}:[\d]{2}[A-Z]{1}\"\/><AuthnStatement AuthnInstant=\"[\d]{4}-[\d]{2}-[\d]{2}[A-Z]{1}[\d]{2}:[\d]{2}:[\d]{2}[A-Z]{1}\"><AuthnContext><AuthnContextClassRef>urn:oasis:names:tc:SAML:2\.0:ac:classes:Password<\/AuthnContextClassRef><\/AuthnContext><\/AuthnStatement><\/Assertion><\/samlp:Response>/
14
+
15
+ Signature_re = /<Signature xmlns='http:\/\/www\.w3\.org\/2000\/09\/xmldsig#'>\n<SignedInfo>\n<CanonicalizationMethod Algorithm='http:\/\/www\.w3\.org\/TR\/2001\/REC-xml-c14n-20010315#WithComments'\/>\n<SignatureMethod Algorithm='http:\/\/www\.w3\.org\/2000\/09\/xmldsig#rsa-sha1'\/>\n<Reference URI=''>\n<Transforms>\n<Transform Algorithm='http:\/\/www\.w3\.org\/2000\/09\/xmldsig#enveloped-signature'\/>\n<\/Transforms>\n<DigestMethod Algorithm='http:\/\/www\.w3\.org\/2000\/09\/xmldsig#sha1'\/>\n<DigestValue>.{28}<\/DigestValue>\n<\/Reference>\n<\/SignedInfo>\n<SignatureValue>.*<\/SignatureValue>\n<KeyInfo>\n<X509Data>\n<X509Certificate>.*=\n<\/X509Certificate>\n<\/X509Data>\n<\/KeyInfo>\n<\/Signature>/m
16
+
17
+ Username = 'sso'
18
+
19
+ FakeCACertPath = File.dirname(__FILE__)+'/fake_cacert.pem'
20
+ FakePrivKeyPath = File.dirname(__FILE__)+'/fake_privkey.pem'
21
+
22
+ def setup
23
+ @sso = XmlProcessResponse.new(FakeCACertPath, FakePrivKeyPath)
24
+ end
25
+
26
+ def test_decode_authn_request
27
+ saml_request_decoded = @sso.send :decode_authn_request, SAMLRequest_encoded
28
+ assert_equal saml_request_decoded, SAMLRequest_decoded
29
+ end
30
+
31
+ def test_get_request_attributes
32
+ acs = @sso.send(:get_request_attributes, (@sso.send :decode_authn_request, SAMLRequest_encoded))
33
+ assert_match(/https:\/\/www.google.com\/a\/.*\/acs/, acs)
34
+ end
35
+
36
+ def test_create_saml_response
37
+ saml_response = @sso.send(:create_saml_response, Username)
38
+ assert_match SAMLResponse_re, saml_response
39
+ end
40
+
41
+ def test_sign_XML
42
+ signed = @sso.send(:sign_XML, @sso.send(:create_saml_response, Username))
43
+ assert_match Signature_re, signed
44
+ end
45
+
46
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ rubygems_version: 0.9.2
3
+ specification_version: 1
4
+ name: google-sso
5
+ version: !ruby/object:Gem::Version
6
+ version: 1.0.2
7
+ date: 2007-07-07 00:00:00 +02:00
8
+ summary: Google Apps Single Sign-on
9
+ require_paths:
10
+ - lib
11
+ email: google-sso@elctech.com
12
+ homepage: http://elctech.com
13
+ rubyforge_project: google-sso
14
+ description: Google Apps Single Sign-on (SSO) support.
15
+ autorequire:
16
+ default_executable:
17
+ bindir: bin
18
+ has_rdoc: true
19
+ required_ruby_version: !ruby/object:Gem::Version::Requirement
20
+ requirements:
21
+ - - ">"
22
+ - !ruby/object:Gem::Version
23
+ version: 0.0.0
24
+ version:
25
+ platform: ruby
26
+ signing_key:
27
+ cert_chain:
28
+ post_install_message:
29
+ authors:
30
+ - Thomas Ormerod, David Palm, and Ryan Garver
31
+ files:
32
+ - Manifest.txt
33
+ - README.txt
34
+ - Rakefile
35
+ - lib/SamlResponseTemplate.xml
36
+ - lib/SignatureTemplate.xml
37
+ - lib/google-sso.rb
38
+ - lib/google-sso/version.rb
39
+ - lib/processresponse.rb
40
+ - lib/xmlcanonicalizer.rb
41
+ - test/test_GoogleSSO.rb
42
+ test_files:
43
+ - test/test_GoogleSSO.rb
44
+ rdoc_options:
45
+ - --main
46
+ - README.txt
47
+ extra_rdoc_files:
48
+ - Manifest.txt
49
+ - README.txt
50
+ executables: []
51
+
52
+ extensions: []
53
+
54
+ requirements: []
55
+
56
+ dependencies:
57
+ - !ruby/object:Gem::Dependency
58
+ name: hoe
59
+ version_requirement:
60
+ version_requirements: !ruby/object:Gem::Version::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 1.2.1
65
+ version: