1 # AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net
3 gem
'ruby-openid', '~> 2' if defined? Gem
6 require 'rack/auth/abstract/handler'
9 require 'openid/extension' #gem
10 require 'openid/store/memory' #gem
15 @env['rack.auth.openid.request']
19 @env['rack.auth.openid.response']
25 # Rack::Auth::OpenID provides a simple method for setting up an OpenID
26 # Consumer. It requires the ruby-openid library from janrain to operate,
27 # as well as a rack method of session management.
29 # The ruby-openid home page is at http://openidenabled.com/ruby-openid/.
31 # The OpenID specifications can be found at
32 # http://openid.net/specs/openid-authentication-1_1.html
34 # http://openid.net/specs/openid-authentication-2_0.html. Documentation
35 # for published OpenID extensions and related topics can be found at
36 # http://openid.net/developers/specs/.
38 # It is recommended to read through the OpenID spec, as well as
39 # ruby-openid's documentation, to understand what exactly goes on. However
40 # a setup as simple as the presented examples is enough to provide
41 # Consumer functionality.
43 # This library strongly intends to utilize the OpenID 2.0 features of the
44 # ruby-openid library, which provides OpenID 1.0 compatiblity.
46 # NOTE: Due to the amount of data that this library stores in the
47 # session, Rack::Session::Cookie may fault.
51 class NoSession
< RuntimeError
; end
52 class BadExtension
< RuntimeError
; end
53 # Required for ruby-openid
54 ValidStatus
= [:success, :setup_needed, :cancel, :failure]
58 # The first argument is the realm, identifying the site they are trusting
59 # with their identity. This is required, also treated as the trust_root
60 # in OpenID 1.x exchanges.
62 # The optional second argument is a hash of options.
66 # <tt>:return_to</tt> defines the url to return to after the client
67 # authenticates with the openid service provider. This url should point
68 # to where Rack::Auth::OpenID is mounted. If <tt>:return_to</tt> is not
69 # provided, return_to will be the current url which allows flexibility
72 # <tt>:session_key</tt> defines the key to the session hash in the env.
73 # It defaults to 'rack.session'.
75 # <tt>:openid_param</tt> defines at what key in the request parameters to
76 # find the identifier to resolve. As per the 2.0 spec, the default is
77 # 'openid_identifier'.
79 # <tt>:store</tt> defined what OpenID Store to use for persistant
80 # information. By default a Store::Memory will be used.
82 # <tt>:immediate</tt> as true will make initial requests to be of an
83 # immediate type. This is false by default. See OpenID specification
86 # <tt>:extensions</tt> should be a hash of openid extension
87 # implementations. The key should be the extension main module, the value
88 # should be an array of arguments for extension::Request.new.
89 # The hash is iterated over and passed to #add_extension for processing.
90 # Please see #add_extension for further documentation.
94 # simple_oid = OpenID.new('http://mysite.com/')
96 # return_oid = OpenID.new('http://mysite.com/', {
97 # :return_to => 'http://mysite.com/openid'
100 # complex_oid = OpenID.new('http://mysite.com/',
101 # :immediate => true,
103 # ::OpenID::SReg => [['email'],['nickname']]
109 # Most of the functionality of this library is encapsulated such that
110 # expansion and overriding functions isn't difficult nor tricky.
111 # Alternately, to avoid opening up singleton objects or subclassing, a
112 # wrapper rack middleware can be composed to act upon Auth::OpenID's
113 # responses. See #check and #finish for locations of pertinent data.
117 # To change the responses that Auth::OpenID returns, override the methods
118 # #redirect, #bad_request, #unauthorized, #access_denied, and
119 # #foreign_server_failure.
121 # Additionally #confirm_post_params is used when the URI would exceed
122 # length limits on a GET request when doing the initial verification
127 # To change methods of processing completed transactions, override the
128 # methods #success, #setup_needed, #cancel, and #failure. Please ensure
129 # the returned object is a rack compatible response.
131 # The first argument is an OpenID::Response, the second is a
132 # Rack::Request of the current request, the last is the hash used in
133 # ruby-openid handling, which can be found manually at
134 # env['rack.session'][:openid].
136 # This is useful if you wanted to expand the processing done, such as
137 # setting up user accounts.
139 # oid_app = Rack::Auth::OpenID.new realm, :return_to => return_to
140 # def oid_app.success oid, request, session
141 # user = Models::User[oid.identity_url]
142 # user ||= Models::User.create_from_openid oid
143 # request['rack.session'][:user] = user.id
144 # redirect MyApp.site_home
147 # site_map['/openid'] = oid_app
148 # map = Rack::URLMap.new site_map
151 def initialize(realm
, options
={})
153 raise ArgumentError
, "Invalid realm: #{realm}" \
154 unless realm
.absolute
? \
155 and realm
.fragment
.nil? \
156 and realm
.scheme
=~
/^https?$/ \
157 and realm
.host
=~
/^(\*\.)?#{URI::REGEXP::PATTERN::URIC_NO_SLASH}+/
158 realm
.path
= '/' if realm
.path
.empty
?
161 if ruri
= options
[:return_to]
163 raise ArgumentError
, "Invalid return_to: #{ruri}" \
164 unless ruri
.absolute
? \
165 and ruri
.scheme
=~
/^https?$/ \
166 and ruri
.fragment
.nil?
167 raise ArgumentError
, "return_to #{ruri} not within realm #{realm}" \
168 unless self.within_realm
?(ruri
)
169 @return_to = ruri
.to_s
172 @session_key = options
[:session_key] || 'rack.session'
173 @openid_param = options
[:openid_param] || 'openid_identifier'
174 @store = options
[:store] || ::OpenID::Store::Memory.new
175 @immediate = !!options
[:immediate]
178 if extensions
= options
.delete(:extensions)
179 extensions
.each
do |ext
, args
|
180 add_extension ext
, *args
184 # Undocumented, semi-experimental
185 @anonymous = !!options
[:anonymous]
188 attr_reader
:realm, :return_to, :session_key, :openid_param, :store,
189 :immediate, :extensions
191 # Sets up and uses session data at <tt>:openid</tt> within the session.
192 # Errors in this setup will raise a NoSession exception.
194 # If the parameter 'openid.mode' is set, which implies a followup from
195 # the openid server, processing is passed to #finish and the result is
196 # returned. However, if there is no appropriate openid information in the
197 # session, a 400 error is returned.
199 # If the parameter specified by <tt>options[:openid_param]</tt> is
200 # present, processing is passed to #check and the result is returned.
202 # If neither of these conditions are met, #unauthorized is called.
205 env['rack.auth.openid'] = self
206 env_session
= env[@session_key]
207 unless env_session
and env_session
.is_a
?(Hash
)
208 raise NoSession
, 'No compatible session'
210 # let us work in our own namespace...
211 session
= (env_session
[:openid] ||= {})
212 unless session
and session
.is_a
?(Hash
)
213 raise NoSession
, 'Incompatible openid session'
216 request
= Rack
::Request.new(env)
217 consumer
= ::OpenID::Consumer.new(session
, @store)
219 if mode
= request
.GET
['openid.mode']
220 if session
.key
?(:openid_param)
221 finish(consumer
, session
, request
)
225 elsif request
.GET
[@openid_param]
226 check(consumer
, session
, request
)
232 # As the first part of OpenID consumer action, #check retrieves the data
233 # required for completion.
235 # If all parameters fit within the max length of a URI, a 303 redirect
236 # will be returned. Otherwise #confirm_post_params will be called.
238 # Any messages from OpenID's request are logged to env['rack.errors']
240 # <tt>env['rack.auth.openid.request']</tt> is the openid checkid request
243 # <tt>session[:openid_param]</tt> is set to the openid identifier
244 # provided by the user.
246 # <tt>session[:return_to]</tt> is set to the return_to uri given to the
249 def check(consumer
, session
, req
)
250 oid
= consumer
.begin(req
.GET
[@openid_param], @anonymous)
251 req
.env['rack.auth.openid.request'] = oid
252 req
.env['rack.errors'].puts(oid
.message
)
256 extensions
.each
do |ext
,args
|
257 oid
.add_extension(ext
::Request.new(*args
))
260 session
[:openid_param] = req
.GET
[openid_param
]
261 return_to_uri
= return_to
? return_to
: req
.url
262 session
[:return_to] = return_to_uri
263 immediate
= session
.key
?(:setup_needed) ? false : immediate
265 if oid
.send_redirect
?(realm
, return_to_uri
, immediate
)
266 uri
= oid
.redirect_url(realm
, return_to_uri
, immediate
)
269 confirm_post_params(oid
, realm
, return_to_uri
, immediate
)
271 rescue ::OpenID::DiscoveryFailure => e
272 # thrown from inside OpenID::Consumer#begin by yadis stuff
273 req
.env['rack.errors'].puts([e
.message
, *e
.backtrace
]*"\n")
274 return foreign_server_failure
277 # This is the final portion of authentication.
278 # If successful, a redirect to the realm is be returned.
279 # Data gathered from extensions are stored in session[:openid] with the
280 # extension's namespace uri as the key.
282 # Any messages from OpenID's response are logged to env['rack.errors']
284 # <tt>env['rack.auth.openid.response']</tt> will contain the openid
287 def finish(consumer
, session
, req
)
288 oid
= consumer
.complete(req
.GET
, req
.url
)
289 req
.env['rack.auth.openid.response'] = oid
290 req
.env['rack.errors'].puts(oid
.message
)
293 raise unless ValidStatus
.include?(oid
.status
)
294 __send__(oid
.status
, oid
, req
, session
)
297 # The first argument should be the main extension module.
298 # The extension module should contain the constants:
299 # * class Request, should have OpenID::Extension as an ancestor
300 # * class Response, should have OpenID::Extension as an ancestor
301 # * string NS_URI, which defining the namespace of the extension
303 # All trailing arguments will be passed to extension::Request.new in
305 # The openid response will be passed to
306 # extension::Response#from_success_response, #get_extension_args will be
307 # called on the result to attain the gathered data.
309 # This method returns the key at which the response data will be found in
310 # the session, which is the namespace uri by default.
312 def add_extension(ext
, *args
)
313 raise BadExtension
unless valid_extension
?(ext
)
314 extensions
[ext
] = args
318 # Checks the validitity, in the context of usage, of a submitted
321 def valid_extension
?(ext
)
322 if not %w
[NS_URI Request Response
].all
?{|c
| ext
.const_defined
?(c
) }
323 raise ArgumentError
, 'Extension is missing constants.'
324 elsif not ext
::Response.respond_to
?(:from_success_response)
325 raise ArgumentError
, 'Response is missing required method.'
332 # Checks the provided uri to ensure it'd be considered within the realm.
333 # is currently not compatible with wildcard realms.
335 def within_realm
? uri
336 uri
= URI
.parse(uri
.to_s
)
337 realm
= URI
.parse(self.realm
)
338 return false unless uri
.absolute
?
339 return false unless uri
.path
[0, realm
.path
.size
] == realm
.path
340 return false unless uri
.host
== realm
.host
or realm
.host
[/^\*\./]
341 # for wildcard support, is awkward with URI limitations
342 realm_match
= Regexp
.escape(realm
.host
).
343 sub(/^\*\./,"^#{URI::REGEXP::PATTERN::URIC_NO_SLASH}+.")+'$'
344 return false unless uri
.host
.match(realm_match
)
347 alias_method
:include?, :within_realm?
351 ### These methods define some of the boilerplate responses.
353 # Returns an html form page for posting to an Identity Provider if the
354 # GET request would exceed the upper URI length limit.
356 def confirm_post_params(oid
, realm
, return_to
, immediate
)
357 Rack
::Response.new
.finish
do |r
|
358 r
.write
'<html><head><title>Confirm...</title></head><body>'
359 r
.write oid
.form_markup(realm
, return_to
, immediate
)
360 r
.write
'</body></html>'
364 # Returns a 303 redirect with the destination of that provided by the
368 [ 303, {'Content-Length'=>'0', 'Content-Type'=>'text/plain',
373 # Returns an empty 400 response.
376 [ 400, {'Content-Type'=>'text/plain', 'Content-Length'=>'0'},
380 # Returns a basic unauthorized 401 response.
383 [ 401, {'Content-Type' => 'text/plain', 'Content-Length' => '13'},
387 # Returns a basic access denied 403 response.
390 [ 403, {'Content-Type' => 'text/plain', 'Content-Length' => '14'},
394 # Returns a 503 response to be used if communication with the remote
395 # OpenID server fails.
397 def foreign_server_failure
398 [ 503, {'Content-Type'=>'text/plain', 'Content-Length' => '23'},
399 ['Foreign server failure.'] ]
404 ### These methods are called after a transaction is completed, depending
405 # on its outcome. These should all return a rack compatible response.
406 # You'd want to override these to provide additional functionality.
408 # Called to complete processing on a successful transaction.
409 # Within the openid session, :openid_identity and :openid_identifier are
410 # set to the user friendly and the standard representation of the
411 # validated identity. All other data in the openid session is cleared.
413 def success(oid
, request
, session
)
415 session
[:openid_identity] = oid
.display_identifier
416 session
[:openid_identifier] = oid
.identity_url
417 extensions
.keys
.each
do |ext
|
418 label
= ext
.name
[/[^:]+$/].downcase
419 response
= ext
::Response.from_success_response(oid
)
420 session
[label
] = response
.data
425 # Called if the Identity Provider indicates further setup by the user is
427 # The identifier is retrived from the openid session at :openid_param.
428 # And :setup_needed is set to true to prevent looping.
430 def setup_needed(oid
, request
, session
)
431 identifier
= session
[:openid_param]
432 session
[:setup_needed] = true
433 redirect req
.script_name
+ '?' + openid_param
+ '=' + identifier
436 # Called if the user indicates they wish to cancel identification.
437 # Data within openid session is cleared.
439 def cancel(oid
, request
, session
)
444 # Called if the Identity Provider indicates the user is unable to confirm
445 # their identity. Data within the openid session is left alone, in case
446 # of swarm auth attacks.
448 def failure(oid
, request
, session
)
453 # A class developed out of the request to use OpenID as an authentication
454 # middleware. The request will be sent to the OpenID instance unless the
455 # block evaluates to true. For example in rackup, you can use it as such:
457 # use Rack::Session::Pool
458 # use Rack::Auth::OpenIDAuth, realm, openid_options do |env|
459 # env['rack.session'][:authkey] == a_string
465 # app = Rack::Auth::OpenIDAuth.new app, realm, openid_options, &auth
467 class OpenIDAuth
< Rack
::Auth::AbstractHandler
469 def initialize(app
, realm
, options
={}, &auth
)
470 @oid = OpenID
.new(realm
, options
)
475 to
= auth
.call(env) ? @app : @oid