warden-googleapps 0.0.1

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