warden-googleapps 0.0.1

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,265 @@
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::GoogleDiscovery.cache = ...
33
+ #
34
+ # The cache must implement methods read(key) and write(key,value)
35
+ #
36
+ # In some cases additional setup is required, particularly to set the location of trusted
37
+ # root certificates for validating XRDS signatures. If standard locations don't work, additional
38
+ # files and directories can be added via:
39
+ #
40
+ # OpenID::SimpleSign.store.add_file(path_to_cacert_pem)
41
+ #
42
+ # or
43
+ #
44
+ # OpenID::SimpleSign.store.add_path(path_to_ca_dir)
45
+ #
46
+ # TODO:
47
+ # - Memcache support for caching host-meta and site XRDS docs
48
+ # - Better packaging (gem/rails)
49
+ module OpenID
50
+
51
+ class << self
52
+ alias_method :default_discover, :discover
53
+ end
54
+
55
+ def self.discover(uri)
56
+ discovery = GoogleDiscovery.new
57
+ info = discovery.perform_discovery(uri)
58
+ if not info.nil?
59
+ return info
60
+ end
61
+ return self.default_discover(uri)
62
+ end
63
+
64
+ # Handles the bulk of Google's modified discovery prototcol
65
+ # See http://groups.google.com/group/google-federated-login-api/web/openid-discovery-for-hosted-domains
66
+ class GoogleDiscovery
67
+
68
+ begin
69
+ @@cache = RAILS_CACHE
70
+ rescue
71
+ @@cache = nil
72
+ end
73
+
74
+ NAMESPACES = {
75
+ 'xrds' => 'xri://$xrd*($v*2.0)',
76
+ 'xrd' => 'xri://$xrds',
77
+ 'openid' => 'http://namespace.google.com/openid/xmlns'
78
+ }
79
+
80
+ # Main entry point for discovery. Attempts to detect whether or not the URI is a raw domain name ('mycompany.com')
81
+ # vs. a user's claimed ID ('http://mycompany.com/openid?id=12345') and performs the site or user discovery appropriately
82
+ def perform_discovery(uri)
83
+ begin
84
+ parsed_uri = URI::parse(uri)
85
+ if parsed_uri.scheme.nil?
86
+ return discover_site(uri)
87
+ end
88
+ return discover_user(parsed_uri.host, uri)
89
+ rescue
90
+ # If we fail, just return nothing and fallback on default discovery mechanisms
91
+ return nil
92
+ end
93
+ end
94
+
95
+ # Handles discovery for a user's claimed ID.
96
+ def discover_user(domain, claimed_id)
97
+ url = fetch_host_meta(domain)
98
+ if url.nil?
99
+ return nil # Not a Google Apps domain
100
+ end
101
+ xrds = fetch_xrds(domain, url)
102
+ user_url, authority = get_user_xrds_url(xrds, claimed_id)
103
+ user_xrds = fetch_xrds(authority, user_url, false)
104
+ return if user_xrds.nil?
105
+
106
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(claimed_id, user_xrds)
107
+ return [claimed_id, OpenID.get_op_or_user_services(endpoints)]
108
+ end
109
+
110
+ # Handles discovery for a domain
111
+ def discover_site(domain)
112
+ url = fetch_host_meta(domain)
113
+ if url.nil?
114
+ return nil # Not a Google Apps domain
115
+ end
116
+ xrds = fetch_xrds(domain, url)
117
+ unless xrds.nil?
118
+ endpoints = OpenID::OpenIDServiceEndpoint.from_xrds(domain, xrds)
119
+ return [domain, OpenID.get_op_or_user_services(endpoints)]
120
+ end
121
+ return nil
122
+ end
123
+
124
+ # Kickstart the discovery process by checking against Google's well-known location for hosted domains.
125
+ # This gives us the location of the site's XRDS doc
126
+ def fetch_host_meta(domain)
127
+ cached_value = get_cache(domain)
128
+ return cached_value unless cached_value.nil?
129
+
130
+ host_meta_url = "https://www.google.com/accounts/o8/.well-known/host-meta?hd=#{CGI::escape(domain)}"
131
+ http_resp = OpenID.fetch(host_meta_url)
132
+ if http_resp.code != "200" and http_resp.code != "206"
133
+ return nil
134
+ end
135
+ matches = /Link: <(.*)>/.match( http_resp.body )
136
+ if matches.nil?
137
+ return nil
138
+ end
139
+ put_cache(domain, matches[1])
140
+ return matches[1]
141
+ end
142
+
143
+ # Fetches the XRDS and verifies the signature and authority for the doc
144
+ def fetch_xrds(authority, url, cache=true)
145
+ return if url.nil?
146
+
147
+ cached_xrds = get_cache(url)
148
+ return cached_xrds unless cached_xrds.nil?
149
+
150
+ http_resp = OpenID.fetch(url)
151
+ return if http_resp.code != "200" and http_resp.code != "206"
152
+
153
+ body = http_resp.body
154
+ signature = http_resp["Signature"]
155
+ signed_by = SimpleSign.verify(body, signature)
156
+ if !signed_by.casecmp(authority) or !signed_by.casecmp('hosted-id.google.com')
157
+ return false # Signed, but not by the right domain.
158
+ end
159
+
160
+
161
+ # Everything is OK
162
+ if cache
163
+ put_cache(url, body)
164
+ end
165
+ return body
166
+ end
167
+
168
+ # Process the URITemplate in the XRDS to derive the location of the claimed id's XRDS
169
+ def get_user_xrds_url(xrds, claimed_id)
170
+ types_to_match = ['http://www.iana.org/assignments/relation/describedby']
171
+ services = OpenID::Yadis::apply_filter(claimed_id, xrds)
172
+ services.each do | service |
173
+ if service.match_types(types_to_match)
174
+ template = REXML::XPath.first(service.service_element, '//openid:URITemplate', NAMESPACES)
175
+ authority = REXML::XPath.first(service.service_element, '//openid:NextAuthority', NAMESPACES)
176
+ url = template.text.gsub('{%uri}', CGI::escape(claimed_id))
177
+ return [url, authority.text]
178
+ end
179
+ end
180
+ end
181
+
182
+ def put_cache(key, item)
183
+ return if @@cache.nil?
184
+ @@cache.write("__GAPPS_OPENID__#{key}", item)
185
+ end
186
+
187
+ def get_cache(key)
188
+ return nil if @@cache.nil?
189
+ return @@cache.read("__GAPPS_OPENID__#{key}")
190
+ end
191
+ end
192
+
193
+ # Basic implementation of the XML Simple Sign algorithm. Currently only supports
194
+ # RSA-SHA1
195
+ class SimpleSign
196
+
197
+ @@store = nil
198
+
199
+ C14N_RAW_OCTETS = 'http://docs.oasis-open.org/xri/xrd/2009/01#canonicalize-raw-octets'
200
+ SIGN_RSA_SHA1 = 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
201
+
202
+ NAMESPACES = {
203
+ 'ds' => 'http://www.w3.org/2000/09/xmldsig#',
204
+ 'xrds' => 'xri://xrds'
205
+ }
206
+
207
+ # Initialize the store
208
+ def self.store
209
+ if @@store.nil?
210
+ ca_bundle_path = File.join(File.dirname(__FILE__), 'ca-bundle.crt')
211
+ @@store = OpenSSL::X509::Store.new
212
+ @@store.set_default_paths
213
+ @@store.add_file(ca_bundle_path)
214
+ end
215
+ return @@store
216
+ end
217
+
218
+ # Extracts the signer's certificates from the XML
219
+ def self.parse_certificates(doc)
220
+ certs = []
221
+ REXML::XPath.each(doc, "//ds:Signature/ds:KeyInfo/ds:X509Data/ds:X509Certificate", NAMESPACES ) { | encoded |
222
+ encoded = encoded.text.strip.scan(/.{1,64}/).join("\n")
223
+ encoded = "-----BEGIN CERTIFICATE-----\n#{encoded}\n-----END CERTIFICATE-----\n"
224
+ cert = OpenSSL::X509::Certificate.new(encoded)
225
+ certs << cert
226
+ }
227
+ return certs
228
+ end
229
+
230
+ # Verifies the chain of trust for the signing certificates
231
+ def self.valid_chain?(chain)
232
+ if chain.nil? or chain.empty?
233
+ return false
234
+ end
235
+ cert = chain.shift
236
+ if self.store.verify(cert)
237
+ return true
238
+ end
239
+ if chain.empty? or not cert.verify(chain.first.public_key)
240
+ return false
241
+ end
242
+ return self.valid_chain?(chain)
243
+ end
244
+
245
+ # Verifies the signature of the doc, returning the CN of the signer if valid
246
+ def self.verify(xml, signature_value)
247
+ raise "Missing signature value" if signature_value.nil?
248
+ decoded_sig = Base64.decode64(signature_value)
249
+
250
+ doc = REXML::Document.new(xml)
251
+ certs = self.parse_certificates(doc)
252
+ raise "No signature in document" if certs.nil? or certs.empty?
253
+
254
+ signing_certificate = certs.first
255
+ raise "Invalid signature" if !signing_certificate.public_key.verify(OpenSSL::Digest::SHA1.new, decoded_sig, xml)
256
+ raise "Certificate chain not valid" if !self.valid_chain?(certs)
257
+
258
+ # Signature is valid, return CN of the subject
259
+ subject = signing_certificate.subject.to_a
260
+ signed_by = subject.last[1]
261
+ return signed_by
262
+ end
263
+ end
264
+
265
+ end
@@ -0,0 +1,76 @@
1
+ Warden::Strategies.add(:google_apps) do
2
+ AxEmail = 'http://axschema.org/contact/email'
3
+ AxFirstName = 'http://axschema.org/namePerson/first'
4
+ AxLastName = 'http://axschema.org/namePerson/last'
5
+
6
+ def authenticate!
7
+ if params['openid.mode']
8
+ response = consumer.complete(params, absolute_url(request, request.path))
9
+ case response.status.to_s
10
+ when 'success'
11
+ profile_data = extract_ax_profile(response)
12
+ user = Warden::GoogleApps::User.new(profile_data[:email],
13
+ profile_data[:first_name],
14
+ profile_data[:last_name],
15
+ response.display_identifier)
16
+ success!(user)
17
+ when 'failure', 'setup_needed', 'cancel'
18
+ fail!('Cound not log in')
19
+ else
20
+ fail!("Unknown Response Status: #{response.status.to_s}")
21
+ end
22
+ elsif params['RelayState']
23
+ raise GoogleAppsMisconfiguredError, "Warden::GoogleApps only works with OpenID Federed Login for Google Apps"
24
+ else
25
+ google_discovery = OpenID.discover(open_id_endpoint)
26
+ open_id_request = consumer.begin(google_discovery.first)
27
+ add_ax_fields(open_id_request)
28
+ redirect!(open_id_request.redirect_url(absolute_url(request), absolute_url(request)))
29
+ end
30
+ end
31
+
32
+ private
33
+ def consumer
34
+ @consumer ||= ::OpenID::Consumer.new(request.session, open_id_store)
35
+ end
36
+
37
+ def open_id_store
38
+ ::OpenID::Store::Filesystem.new("#{Dir.tmpdir}/tmp/openid")
39
+ end
40
+
41
+ def open_id_endpoint
42
+ # TODO Get this exposed properly via warden
43
+ domain = env['warden'].instance_variable_get('@config')[:google_apps_domain]
44
+ 'https://www.google.com/accounts/o8/site-xrds?hd=%s' % domain
45
+ end
46
+
47
+ def add_ax_fields(open_id_request)
48
+ ax_request = ::OpenID::AX::FetchRequest.new
49
+
50
+ [ AxEmail, AxFirstName, AxLastName ].each do |field|
51
+ ax_request.add(::OpenID::AX::AttrInfo.new(field, nil, true))
52
+ end
53
+ open_id_request.add_extension(ax_request)
54
+ end
55
+
56
+ def extract_ax_profile(open_id_response)
57
+ profile = { }
58
+ if ax_response = OpenID::AX::FetchResponse.from_success_response(open_id_response)
59
+ profile_data = ax_response.data
60
+ profile[:email] = profile_data[AxEmail]
61
+ profile[:last_name] = profile_data[AxLastName]
62
+ profile[:first_name] = profile_data[AxFirstName]
63
+ end
64
+ profile
65
+ end
66
+
67
+ def absolute_url(request, suffix = nil)
68
+ port_part = case request.scheme
69
+ when "http"
70
+ request.port == 80 ? "" : ":#{request.port}"
71
+ when "https"
72
+ request.port == 443 ? "" : ":#{request.port}"
73
+ end
74
+ "#{request.scheme}://#{request.host}#{port_part}#{suffix}"
75
+ end
76
+ end
@@ -0,0 +1,9 @@
1
+ module Warden
2
+ module GoogleApps
3
+ class User < Struct.new(:email, :first_name, :last_name, :identity_url)
4
+ def full_name
5
+ "#{first_name} #{last_name}"
6
+ end
7
+ end
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: warden-googleapps
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Corey Donohoe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-02 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: warden
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: sinatra
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: 0.9.4
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: ruby-openid
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.1.6
44
+ version:
45
+ description: A warden strategy to use Google's Federated OpenID with Google Apps
46
+ email: atmos@atmos.org
47
+ executables: []
48
+
49
+ extensions: []
50
+
51
+ extra_rdoc_files:
52
+ - LICENSE
53
+ - TODO
54
+ files:
55
+ - LICENSE
56
+ - README.md
57
+ - Rakefile
58
+ - TODO
59
+ - lib/warden-googleapps/ca-bundle.crt
60
+ - lib/warden-googleapps/gapps_openid.rb
61
+ - lib/warden-googleapps/strategy.rb
62
+ - lib/warden-googleapps/user.rb
63
+ - lib/warden-googleapps.rb
64
+ has_rdoc: true
65
+ homepage: http://github.com/atmos/warden-googleapps
66
+ licenses: []
67
+
68
+ post_install_message:
69
+ rdoc_options: []
70
+
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: "0"
78
+ version:
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: "0"
84
+ version:
85
+ requirements: []
86
+
87
+ rubyforge_project:
88
+ rubygems_version: 1.3.5
89
+ signing_key:
90
+ specification_version: 3
91
+ summary: A warden strategy to use Google's Federated OpenID with Google Apps
92
+ test_files: []
93
+