ruby-openid-apps-discovery 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.
Files changed (3) hide show
  1. data/lib/ca-bundle.crt +2794 -0
  2. data/lib/gapps_openid.rb +315 -0
  3. metadata +67 -0
@@ -0,0 +1,315 @@
1
+ require "openid"
2
+ require "openid/fetchers"
3
+ require "openid/consumer/discovery"
4
+ require 'rexml/document'
5
+ require 'rexml/element'
6
+ require 'rexml/xpath'
7
+ require 'openssl'
8
+ require 'base64'
9
+
10
+ # Copyright 2009 Google Inc.
11
+ #
12
+ # Licensed under the Apache License, Version 2.0 (the "License")
13
+ # you may not use this file except in compliance with the License.
14
+ # You may obtain a copy of the License at
15
+ #
16
+ # http://www.apache.org/licenses/LICENSE-2.0
17
+ #
18
+ # Unless required by applicable law or agreed to in writing, software
19
+ # distributed under the License is distributed on an "AS IS" BASIS,
20
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21
+ # See the License for the specific language governing permissions and
22
+ # limitations under the License.
23
+
24
+ # Extends ruby-openid to support the discovery protocol used by Google Apps. Usage is
25
+ # generally simple. Where using ruby-openid's Consumer, add the line
26
+ #
27
+ # require 'gapps_openid'
28
+ #
29
+ # Caching of discovery information is enabled when used with rails. In other environments,
30
+ # a cache can be set via:
31
+ #
32
+ # OpenID.cache = ...
33
+ #
34
+ # The cache must implement methods read(key) and write(key,value)
35
+ #
36
+ # Similarly, logging will attempt to use the default Rail's logger, but can be overriden
37
+ # by calling
38
+ #
39
+ # OpenID.logger = ...
40
+ #
41
+ # The logger must respond to warn, debug, and info methods
42
+ #
43
+ # In some cases additional setup is required, particularly to set the location of trusted
44
+ # root certificates for validating XRDS signatures. If standard locations don't work, additional
45
+ # files and directories can be added via:
46
+ #
47
+ # OpenID::SimpleSign.store.add_file(path_to_cacert_pem)
48
+ #
49
+ # or
50
+ #
51
+ # OpenID::SimpleSign.store.add_path(path_to_ca_dir)
52
+ #
53
+ # TODO:
54
+ # - Memcache support for caching host-meta and site XRDS docs
55
+ # - Better packaging (gem/rails)
56
+ module OpenID
57
+
58
+ class << self
59
+ alias_method :default_discover, :discover
60
+ attr_accessor :cache, :logger
61
+ end
62
+
63
+ def self.discover(uri)
64
+ discovery = GoogleDiscovery.new
65
+ info = discovery.perform_discovery(uri)
66
+ if not info.nil?
67
+ OpenID.logger.debug("Discovery info = #{info}") unless OpenID.logger.nil?
68
+ return info
69
+ end
70
+ return self.default_discover(uri)
71
+ end
72
+
73
+ # Handles the bulk of Google's modified discovery prototcol
74
+ # See http://groups.google.com/group/google-federated-login-api/web/openid-discovery-for-hosted-domains
75
+ class GoogleDiscovery
76
+
77
+ OpenID.cache = RAILS_CACHE rescue nil
78
+ OpenID.logger = RAILS_DEFAULT_LOGGER rescue nil
79
+
80
+ NAMESPACES = {
81
+ 'xrds' => 'xri://$xrd*($v*2.0)',
82
+ 'xrd' => 'xri://$xrds',
83
+ 'openid' => 'http://namespace.google.com/openid/xmlns'
84
+ }
85
+
86
+ # Main entry point for discovery. Attempts to detect whether or not the URI is a raw domain name ('mycompany.com')
87
+ # vs. a user's claimed ID ('http://mycompany.com/openid?id=12345') and performs the site or user discovery appropriately
88
+ def perform_discovery(uri)
89
+ OpenID.logger.debug("Performing discovery for #{uri}") unless OpenID.logger.nil?
90
+ begin
91
+ domain = uri
92
+ parsed_uri = URI::parse(uri)
93
+ domain = parsed_uri.host unless parsed_uri.host.nil?
94
+ if site_identifier?(parsed_uri)
95
+ return discover_site(domain)
96
+ end
97
+ return discover_user(domain, uri)
98
+ rescue Exception => e
99
+ # If we fail, just return nothing and fallback on default discovery mechanisms
100
+ OpenID.logger.warn("Unexpected exception performing discovery for id #{uri}: #{e}") unless OpenID.logger.nil?
101
+ return nil
102
+ end
103
+ end
104
+
105
+ def site_identifier?(parsed_uri)
106
+ return parsed_uri.scheme.nil? || parsed_uri.path.nil? || parsed_uri.path.strip.empty?
107
+ end
108
+
109
+ # Handles discovery for a user's claimed ID.
110
+ def discover_user(domain, claimed_id)
111
+ OpenID.logger.debug("Discovering user identity #{claimed_id} for domain #{domain}") unless OpenID.logger.nil?
112
+ url = fetch_host_meta(domain)
113
+ if url.nil?
114
+ OpenID.logger.debug("#{domain} is not a Google Apps domain, aborting") unless OpenID.logger.nil?
115
+ return nil # Not a Google Apps domain
116
+ end
117
+
118
+ xrds, signed = fetch_secure_xrds(domain, url)
119
+
120
+ unless xrds.nil?
121
+ # TODO - Need to propogate secure discovery info up through stack
122
+ user_url, authority = get_user_xrds_url(xrds, claimed_id)
123
+ user_xrds, signed = fetch_secure_xrds(domain, user_url, false)
124
+
125
+ # No user xrds -- likely that identifier was just OP identifier
126
+ if user_xrds.nil?
127
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(domain, xrds)
128
+ return [claimed_id, OpenID.get_op_or_user_services(endpoints)]
129
+ end
130
+
131
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(claimed_id, user_xrds)
132
+ return [claimed_id, OpenID.get_op_or_user_services(endpoints)]
133
+ end
134
+ end
135
+
136
+ # Handles discovery for a domain
137
+ def discover_site(domain)
138
+ OpenID.logger.debug("Discovering domain #{domain}") unless OpenID.logger.nil?
139
+ url = fetch_host_meta(domain)
140
+ if url.nil?
141
+ OpenID.logger.debug("#{domain} is not a Google Apps domain, aborting") unless OpenID.logger.nil?
142
+ return nil # Not a Google Apps domain
143
+ end
144
+ xrds, secure = fetch_secure_xrds(domain, url)
145
+
146
+ unless xrds.nil?
147
+ # TODO - Need to propogate secure discovery info up through stack
148
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(domain, xrds)
149
+ return [domain, OpenID.get_op_or_user_services(endpoints)]
150
+ end
151
+ return nil
152
+ end
153
+
154
+ # Kickstart the discovery process by checking against Google's well-known location for hosted domains.
155
+ # This gives us the location of the site's XRDS doc
156
+ def fetch_host_meta(domain)
157
+ cached_value = get_cache(domain)
158
+ return cached_value unless cached_value.nil?
159
+
160
+ host_meta_url = "https://www.google.com/accounts/o8/.well-known/host-meta?hd=#{CGI::escape(domain)}"
161
+ http_resp = fetch_url(host_meta_url)
162
+ return nil if http_resp.nil?
163
+
164
+ matches = /Link: <(.*)>/.match( http_resp.body )
165
+ if matches.nil?
166
+ OpenID.logger.debug("No link tag found at #{host_meta_url}") unless OpenID.logger.nil?
167
+ return nil
168
+ end
169
+ put_cache(domain, matches[1])
170
+ return matches[1]
171
+ end
172
+
173
+ def fetch_url(url)
174
+ http_resp = OpenID.fetch(url)
175
+ if http_resp.code != "200" and http_resp.code != "206"
176
+ OpenID.logger.debug("Received #{http_resp.code} when fetching #{url}") unless OpenID.logger.nil?
177
+ return nil
178
+ end
179
+ return http_resp
180
+ end
181
+
182
+ # Fetches the XRDS and verifies the signature and authority for the doc
183
+ def fetch_secure_xrds(authority, url, cache=true)
184
+ return if url.nil?
185
+
186
+ OpenID.logger.debug("Retrieving XRDS from #{url}") unless OpenID.logger.nil?
187
+
188
+ cached_xrds = get_cache("XRDS_#{url}")
189
+ return cached_xrds unless cached_xrds.nil?
190
+
191
+ http_resp = fetch_url(url)
192
+ return nil if http_resp.nil?
193
+
194
+ body = http_resp.body
195
+ put_cache("XRDS_#{url}", body)
196
+
197
+ signature = http_resp["Signature"]
198
+ signed_by = SimpleSign.verify(body, signature)
199
+
200
+ if signed_by.nil?
201
+ put_cache("XRDS_#{url}", body) if cache
202
+ return [body, false]
203
+ elsif signed_by.casecmp(authority) || signed_by.casecmp('hosted-id.google.com')
204
+ put_cache("XRDS_#{url}", body) if cache
205
+ return [body, true]
206
+ else
207
+ OpenID.logger.warn("Expected signature from #{authority} but found #{signed_by}") unless OpenID.logger.nil?
208
+ return nil # Signed, but not by the right domain.
209
+ end
210
+ end
211
+
212
+ # Process the URITemplate in the XRDS to derive the location of the claimed id's XRDS
213
+ def get_user_xrds_url(xrds, claimed_id)
214
+ types_to_match = ['http://www.iana.org/assignments/relation/describedby']
215
+ services = OpenID::Yadis::apply_filter(claimed_id, xrds)
216
+ services.each do | service |
217
+ if service.match_types(types_to_match)
218
+ template = REXML::XPath.first(service.service_element, '//openid:URITemplate', NAMESPACES)
219
+ authority = REXML::XPath.first(service.service_element, '//openid:NextAuthority', NAMESPACES)
220
+ url = template.text.gsub('{%uri}', CGI::escape(claimed_id))
221
+ return [url, authority.text]
222
+ end
223
+ end
224
+ end
225
+
226
+ def put_cache(key, item)
227
+ return if OpenID.cache.nil?
228
+ OpenID.cache.write("__GAPPS_OPENID__#{key}", item)
229
+ end
230
+
231
+ def get_cache(key)
232
+ return nil if OpenID.cache.nil?
233
+ return OpenID.cache.read("__GAPPS_OPENID__#{key}")
234
+ end
235
+ end
236
+
237
+ # Basic implementation of the XML Simple Sign algorithm. Currently only supports
238
+ # RSA-SHA1
239
+ class SimpleSign
240
+
241
+ @@store = nil
242
+
243
+ C14N_RAW_OCTETS = 'http://docs.oasis-open.org/xri/xrd/2009/01#canonicalize-raw-octets'
244
+ SIGN_RSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
245
+
246
+ NAMESPACES = {
247
+ 'ds' => 'http://www.w3.org/2000/09/xmldsig#',
248
+ 'xrds' => 'xri://xrds'
249
+ }
250
+
251
+ # Initialize the store
252
+ def self.store
253
+ if @@store.nil?
254
+ OpenID.logger.info("Initializing CA bundle") unless OpenID.logger.nil?
255
+ ca_bundle_path = File.join(File.dirname(__FILE__), 'ca-bundle.crt')
256
+ @@store = OpenSSL::X509::Store.new
257
+ @@store.set_default_paths
258
+ @@store.add_file(ca_bundle_path)
259
+ end
260
+ return @@store
261
+ end
262
+
263
+ # Extracts the signer's certificates from the XML
264
+ def self.parse_certificates(doc)
265
+ certs = []
266
+ REXML::XPath.each(doc, "//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate", NAMESPACES ) { | encoded |
267
+ encoded = encoded.text.strip.scan(/.{1,64}/).join("\n")
268
+ encoded = "-----BEGIN CERTIFICATE-----\n#{encoded}\n-----END CERTIFICATE-----\n"
269
+ cert = OpenSSL::X509::Certificate.new(encoded)
270
+ certs << cert
271
+ }
272
+ return certs
273
+ end
274
+
275
+ # Verifies the chain of trust for the signing certificates
276
+ def self.valid_chain?(chain)
277
+ if chain.nil? or chain.empty?
278
+ return false
279
+ end
280
+ cert = chain.shift
281
+ if self.store.verify(cert)
282
+ return true
283
+ end
284
+ if chain.empty? or not cert.verify(chain.first.public_key)
285
+ return false
286
+ end
287
+ return self.valid_chain?(chain)
288
+ end
289
+
290
+ # Verifies the signature of the doc, returning the CN of the signer if valid
291
+ def self.verify(xml, signature_value)
292
+ doc = REXML::Document.new(xml)
293
+
294
+ return nil if REXML::XPath.first(doc, "//ds:Signature").nil? and signature_value.nil?
295
+
296
+ decoded_sig = Base64.decode64(signature_value)
297
+ certs = self.parse_certificates(doc)
298
+ raise "No signature in document" if certs.nil? or certs.empty?
299
+ raise "Missing signature value" if signature_value.nil?
300
+
301
+
302
+ signing_certificate = certs.first
303
+ raise "Invalid signature" if !signing_certificate.public_key.verify(OpenSSL::Digest::SHA1.new, decoded_sig, xml)
304
+ raise "Certificate chain not valid" if !self.valid_chain?(certs)
305
+
306
+ # Signature is valid, return CN of the subject
307
+ subject = signing_certificate.subject.to_a
308
+ signed_by = subject.last[1]
309
+ return signed_by
310
+ end
311
+ end
312
+
313
+ end
314
+
315
+
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-openid-apps-discovery
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.2
5
+ platform: ruby
6
+ authors: []
7
+
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-05-13 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: ruby-openid
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 2.1.7
24
+ version:
25
+ description: |
26
+ Extension to ruby-openid that enables discovery for Google Apps domains
27
+
28
+ email:
29
+ executables: []
30
+
31
+ extensions: []
32
+
33
+ extra_rdoc_files: []
34
+
35
+ files:
36
+ - lib/gapps_openid.rb
37
+ - lib/ca-bundle.crt
38
+ has_rdoc: true
39
+ homepage:
40
+ licenses: []
41
+
42
+ post_install_message:
43
+ rdoc_options: []
44
+
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: "0"
52
+ version:
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ version:
59
+ requirements: []
60
+
61
+ rubyforge_project:
62
+ rubygems_version: 1.3.5
63
+ signing_key:
64
+ specification_version: 3
65
+ summary: Google Apps support for ruby-openid
66
+ test_files: []
67
+