ruby-openid-apps-discovery 1.0.2

Sign up to get free protection for your applications and to get access to all the features.
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
+