summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Neukirchen <chneukirchen@gmail.com>2008-06-24 13:55:25 +0200
committerChristian Neukirchen <chneukirchen@gmail.com>2008-06-24 13:55:25 +0200
commit2e3c3adc0df92fcd41b32feb4b64bfaae9664385 (patch)
tree58fb1173b20476649e3d4baf9cdd7f719a7d98fd
parentfe22d0fddfe13b4fbcac44e8d804c93e23eb6380 (diff)
parent86d4f36b5e252c76d1ca09fa104cd2aac7f9f46a (diff)
downloadrack-2e3c3adc0df92fcd41b32feb4b64bfaae9664385.tar.gz
Merge commit 'scytrin/master'
-rw-r--r--lib/rack/auth/openid.rb368
-rw-r--r--test/spec_rack_auth_openid.rb53
-rw-r--r--test/spec_rack_utils.rb1
3 files changed, 334 insertions, 88 deletions
diff --git a/lib/rack/auth/openid.rb b/lib/rack/auth/openid.rb
index 9267a98e..f093e8f4 100644
--- a/lib/rack/auth/openid.rb
+++ b/lib/rack/auth/openid.rb
@@ -1,115 +1,309 @@
# AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net
-gem_require 'ruby-openid', '~> 1.0.0' if defined? Gem
-require 'rack/auth/abstract/handler'
-require 'openid'
+gem 'ruby-openid', '~> 2' if defined? Gem
+require 'rack/auth/abstract/handler' #rack
+require 'uri' #std
+require 'pp' #std
+require 'openid' #gem
+require 'openid/store/memory' #gem
module Rack
module Auth
- # Rack::Auth::OpenID provides a simple method for permitting openid
- # based logins. It requires the ruby-openid lib from janrain to operate,
- # as well as some method of session management of a Hash type.
+ # Rack::Auth::OpenID provides a simple method for permitting
+ # openid based logins. It requires the ruby-openid library from
+ # janrain to operate, as well as a rack method of session management.
#
- # After a transaction, the response status object is stored in the
- # environment at rack.auth.openid.status, which can be used in the
- # followup block or in a wrapping application to accomplish
- # additional data maniipulation.
+ # The ruby-openid home page is at http://openidenabled.com/ruby-openid/.
#
- # NOTE: Due to the amount of data that ruby-openid stores in the session,
- # Rack::Session::Cookie may fault.
+ # The OpenID specifications can be found at
+ # http://openid.net/specs/openid-authentication-1_1.html
+ # and
+ # http://openid.net/specs/openid-authentication-2_0.html. Documentation
+ # for published OpenID extensions and related topics can be found at
+ # http://openid.net/developers/specs/.
#
- # A hash of data is stored in the session hash at the key of :openid.
- # The fully canonicalized identity url is stored within at 'identity'.
- # Extension data from 'openid.sreg.nickname' would be stored as
- # { 'nickname' => value }.
+ # It is recommended to read through the OpenID spec, as well as
+ # ruby-openid's documentation, to understand what exactly goes on. However
+ # a setup as simple as the presented examples is enough to provide
+ # functionality.
#
- # NOTE: To my knowledge there is no collision at this point from storage
- # of this manner, if there is please let me know so I may adjust this app
- # to cope.
- # NOTE: This rack application is only compatible with the 1.x.x versions
- # of the ruby-openid library. If rubygems is loaded at require time of
- # this app, the specification will be made. If it is not then the 'openid'
- # library will be required, and will fail if it is not compatible.
+ # This library strongly intends to utilize the OpenID 2.0 features of the
+ # ruby-openid library, while maintaining OpenID 1.0 compatiblity.
+ #
+ # All responses from this rack application will be 303 redirects unless an
+ # error occurs, with the exception of an authentication request requiring
+ # an HTML form submission.
+ #
+ # NOTE: Extensions are not currently supported by this implimentation of
+ # the OpenID rack application due to the complexity of the current
+ # ruby-openid extension handling.
+ #
+ # NOTE: Due to the amount of data that this library stores in the
+ # session, Rack::Session::Cookie may fault.
class OpenID < AbstractHandler
+ class NoSession < RuntimeError; end
# Required for ruby-openid
- OIDStore = ::OpenID::MemoryStore.new
+ OIDStore = ::OpenID::Store::Memory.new
+ HTML = '<html><head><title>%s</title></head><body>%s</body></html>'
# A Hash of options is taken as it's single initializing
- # argument. String keys are taken to be openid protocol
- # extension namespaces.
- #
- # For example: 'sreg' => { 'required' => # 'nickname' }
- #
- # Other keys are taken as options for Rack::Auth::OpenID, normally Symbols.
- # Only :return is required. :trust is highly recommended to be set.
- #
- # * :return defines the url to return to after the client authenticates
- # with the openid service provider. Should point to where this app is
- # mounted. (ex: 'http://mysite.com/openid')
- # * :trust defines the url identifying the site they are actually logging
- # into. (ex: 'http://mysite.com/')
- # * :session_key defines the key to the session hash in the env.
- # (by default it uses 'rack.session')
- def initialize(options={})
- raise ArgumentError, 'No return url provided.' unless options[:return]
- warn 'No trust url provided.' unless options[:trust]
- options[:trust] ||= options[:return]
-
- @options = {
- :session_key => 'rack.session'
+ # argument. For example:
+ #
+ # simple_oid = OpenID.new('http://mysite.com/')
+ #
+ # return_oid = OpenID.new('http://mysite.com/', {
+ # :return_to => 'http://mysite.com/openid'
+ # })
+ #
+ # page_oid = OpenID.new('http://mysite.com/',
+ # :login_good => 'http://mysite.com/auth_good'
+ # )
+ #
+ # = Arguments
+ #
+ # The first argument is the realm, identifying the site they are trusting
+ # with their identity. This is required.
+ #
+ # NOTE: In OpenID 1.x, the realm or trust_root is optional and the
+ # return_to url is required. As this library strives tward ruby-openid
+ # 2.0, and OpenID 2.0 compatibiliy, the realm is required and return_to
+ # is optional. However, this implimentation is still backwards compatible
+ # with OpenID 1.0 servers.
+ #
+ # The optional second argument is a hash of options.
+ #
+ # == Options
+ #
+ # <tt>:return_to</tt> defines the url to return to after the client authenticates with the openid service provider. This url should point to where Rack::Auth::OpenID is mounted. If <tt>:return_to</tt> is not provided, the url will be derived within the ruby-openid implementation.
+ #
+ # <tt>:session_key</tt> defines the key to the session hash in the env. It defaults to 'rack.session'.
+ #
+ # <tt>:openid_param</tt> defines at what key in the request parameters to find the identifier to resolve. As per the 2.0 spec, the default is 'openid_identifier'.
+ #
+ # <tt>:extensions</tt> will specify what extensions are to used with OpenID, of which the format and support of which is yet to be completed.
+ #
+ # <tt>:immediate</tt> as true will make immediate type of requests the default. See the specification documentation.
+ #
+ # === URL options
+ #
+ # <tt>:login_good</tt> is the url to go to after the authentication process has completed.
+ #
+ # <tt>:login_fail</tt> is the url to go to after the authentication process has failed.
+ #
+ # <tt>:login_quit</tt> is the url to go to after the authentication process
+ # has been cancelled.
+ #
+ # === Response options
+ #
+ # <tt>:no_session</tt> should be a rack response to be returned if no or an incompatible session is found.
+ #
+ # <tt>:auth_fail</tt> should be a rack response to be returned if an OpenID::DiscoveryFailure occurs. This is typically due to being unable to access the identity url or identity server.
+ #
+ # <tt>:error</tt> should be a rack response to return if any other generic error would occur and <tt>options[:catch_errors]</tt> is true.
+ def initialize(realm, options={})
+ @realm = realm
+ realm = URI(realm)
+ if realm.path.empty?
+ raise ArgumentError, "Invalid realm path: '#{realm.path}'"
+ elsif not realm.absolute?
+ raise ArgumentError, "Realm '#{@realm}' not absolute"
+ end
+
+ [:return_to, :login_good, :login_fail, :login_quit].each do |key|
+ if options.key? key and luri = URI(options[key])
+ if !luri.absolute?
+ raise ArgumentError, ":#{key} is not an absolute uri: '#{luri}'"
+ end
+ end
+ end
+
+ if options[:return_to] and ruri = URI(options[:return_to])
+ if ruri.path.empty?
+ raise ArgumentError, "Invalid return_to path: '#{ruri.path}'"
+ elsif realm.path != ruri.path[0, realm.path.size]
+ raise ArgumentError, 'return_to not within realm.' \
+ end
+ end
+
+ # TODO: extension support
+ if options.has_key? :extensions
+ warn "Extensions are not currently supported by Rack::Auth::OpenID"
+ end
+
+ @options = {
+ :session_key => 'rack.session',
+ :openid_param => 'openid_identifier',
+ #:return_to, :login_good, :login_fail, :login_quit
+ #:no_session, :auth_fail, :error
+ :store => OIDStore,
+ :immediate => false,
+ :anonymous => false,
+ :catch_errors => false
}.merge(options)
end
+ attr_reader :options
+
+ # It sets up and uses session data at <tt>:openid</tt> within the session. It sets up the ::OpenID::Consumer using the store specified by <tt>options[:store]</tt>.
+ #
+ # If the parameter specified by <tt>options[:openid_param]</tt> is present, processing is passed to #check and the result is returned.
+ #
+ # If the parameter 'openid.mode' is set, implying a followup from the openid server, processing is passed to #finish and the result is returned.
+ #
+ # If neither of these conditions are met, a 400 error is returned.
+ #
+ # If an error is thrown and <tt>options[:catch_errors]</tt> is false, the exception will be reraised. Otherwise a 500 error is returned.
def call(env)
+ env['rack.auth.openid'] = self
+ session = env[@options[:session_key]]
+ unless session and session.is_a? Hash
+ raise(NoSession, 'No compatible session')
+ end
+ # let us work in our own namespace...
+ session = (session[:openid] ||= {})
+ unless session and session.is_a? Hash
+ raise(NoSession, 'Incompatible session')
+ end
+
request = Rack::Request.new env
- return no_session unless session = request.env[@options[:session_key]]
- resp = if request.GET['openid.mode']
- finish session, request.GET, env
- elsif request.GET['openid_url']
- check session, request.GET['openid_url'], env
- else
- bad_request
- end
- end
+ consumer = ::OpenID::Consumer.new session, @options[:store]
- def check(session, oid_url, env)
- consumer = ::OpenID::Consumer.new session, OIDStore
- oid = consumer.begin oid_url
- return auth_fail unless oid.status == ::OpenID::SUCCESS
- @options.each do |ns,s|
- next unless ns.is_a? String
- s.each {|k,v| oid.add_extension_arg(ns, k, v) }
+ if request.params[@options[:openid_param]]
+ check consumer, session, request
+ elsif request.params['openid.mode']
+ finish consumer, session, request
+ else
+ env['rack.errors'].puts "No valid params provided."
+ bad_request
end
- r_url = @options.fetch :return do |k| request.url end
- t_url = @options.fetch :trust
- env['rack.auth.openid.status'] = oid
- return 303, {'Location'=>oid.redirect_url( t_url, r_url )}, []
- end
+ rescue NoSession
+ env['rack.errors'].puts($!.message, *$@)
- def finish(session, params, env)
- consumer = ::OpenID::Consumer.new session, OIDStore
- oid = consumer.complete params
- return bad_login unless oid.status == ::OpenID::SUCCESS
- session[:openid] = {'identity' => oid.identity_url}
- @options.each do |ns,s|
- next unless ns.is_a? String
- oid.extension_response(ns).each{|k,v| session[k]=v }
- end
- env['rack.auth.openid.status'] = oid
- return 303, {'Location'=>@options[:trust]}, []
- end
+ @options. ### Missing or incompatible session
+ fetch :no_session, [ 500,
+ {'Content-Type'=>'text/plain'},
+ $!.message ]
+ rescue
+ env['rack.errors'].puts($!.message, *$@)
- def no_session
+ if not @options[:catch_error]
+ raise($!)
+ end
@options.
- fetch :no_session, [500,{'Content-Type'=>'text/plain'},'No session available.']
+ fetch :error, [ 500,
+ {'Content-Type'=>'text/plain'},
+ 'OpenID has encountered an error.' ]
end
- def auth_fail
- @options.
- fetch :auth_fail, [500, {'Content-Type'=>'text/plain'},'Foreign server failure.']
+
+ # As the first part of OpenID consumer action, #check retrieves the data required for completion.
+ #
+ # * <tt>session[:openid][:openid_param]</tt> is the request parameter requested to be authenticated.
+ # * <tt>session[:openid][:site_return]</tt> is set as the request's HTTP_REFERER if previously unset.
+ # * <tt>env['rack.auth.openid.request']</tt> is the openid checkid request.
+ def check(consumer, session, req)
+ session[:openid_param] = req.params[@options[:openid_param]]
+ oid = consumer.begin(session[:openid_param], @options[:anonymous])
+ pp oid if $DEBUG
+ req.env['rack.auth.openid.request'] = oid
+
+ session[:site_return] ||= req.env['HTTP_REFERER']
+
+ # SETUP_NEEDED check!
+ # see OpenID::Consumer::CheckIDRequest docs
+ query_args = [@realm, *@options.values_at(:return_to, :immediate)]
+ query_args[2] = false if session.key? :setup_needed
+ pp query_args if $DEBUG
+
+ if oid.send_redirect?(*query_args)
+ redirect = oid.redirect_url(*query_args)
+ [ 303, {'Location'=>redirect}, [] ]
+ else
+ # check on 'action' option.
+ formbody = oid.form_markup(*query_args)
+ body = HTML % ['Confirm...', formbody]
+ [ 200, {'Content-Type'=>'text/html'}, body.to_a ]
+ end
+ rescue ::OpenID::DiscoveryFailure => e
+ # thrown from inside OpenID::Consumer#begin by yadis stuff
+ req.env['rack.errors'].puts($!.message, *$@)
+
+ @options. ### Foreign server failed
+ fetch :auth_fail, [ 503,
+ {'Content-Type'=>'text/plain'},
+ 'Foreign server failure.' ]
end
- def bad_login
- @options.
- fetch :bad_login, [401, {'Content-Type'=>'text/plain'},'Identification has failed.']
+
+ # This is the final portion of authentication. Unless any errors outside
+ # of specification occur, a 303 redirect will be returned with Location
+ # determined by the OpenID response type. If none of the response type
+ # :login_* urls are set, the redirect will be set to
+ # <tt>session[:openid][:site_return]</tt>. If <tt>session[:openid][:site_return]</tt> is
+ # unset, the realm will be used.
+ #
+ # Any messages from OpenID's response are appended to the 303 response
+ # body.
+ #
+ # * <tt>env['rack.auth.openid.response']</tt> is the openid response.
+ #
+ # The four valid possible outcomes are:
+ # * failure: <tt>options[:login_fail]</tt> or <tt>session[:site_return]</tt> or the realm
+ # * <tt>session[:openid]</tt> is cleared and any messages are send to rack.errors
+ # * <tt>session[:openid]['authenticated']</tt> is <tt>false</tt>
+ # * success: <tt>options[:login_good]</tt> or <tt>session[:site_return]</tt> or the realm
+ # * <tt>session[:openid]</tt> is cleared
+ # * <tt>session[:openid]['authenticated']</tt> is <tt>true</tt>
+ # * <tt>session[:openid]['identity']</tt> is the actual identifier
+ # * <tt>session[:openid]['identifier']</tt> is the pretty identifier
+ # * cancel: <tt>options[:login_good]</tt> or <tt>session[:site_return]</tt> or the realm
+ # * <tt>session[:openid]</tt> is cleared
+ # * <tt>session[:openid]['authenticated']</tt> is <tt>false</tt>
+ # * setup_needed: resubmits the authentication request. A flag is set for non-immediate handling.
+ # * <tt>session[:openid][:setup_needed]</tt> is set to <tt>true</tt>, which will prevent immediate style openid authentication.
+ def finish(consumer, session, req)
+ oid = consumer.complete(req.params, req.url)
+ pp oid if $DEBUG
+ req.env['rack.auth.openid.response'] = oid
+
+ goto = session.fetch :site_return, @realm
+ body = []
+
+ case oid.status
+ when ::OpenID::Consumer::FAILURE
+ session.clear
+ session['authenticated'] = false
+ req.env['rack.errors'].puts oid.message
+
+ goto = @options[:login_fail] if @option.key? :login_fail
+ body << "Authentication unsuccessful.\n"
+ when ::OpenID::Consumer::SUCCESS
+ session.clear
+ session['authenticated'] = true
+ # Value for unique identification and such
+ session['identity'] = oid.identity_url
+ # Value for display and UI labels
+ session['identifier'] = oid.display_identifier
+
+ goto = @options[:login_good] if @options.key? :login_good
+ body << "Authentication successful.\n"
+ when ::OpenID::Consumer::CANCEL
+ session.clear
+ session['authenticated'] = false
+
+ goto = @options[:login_fail] if @option.key? :login_fail
+ body << "Authentication cancelled.\n"
+ when ::OpenID::Consumer::SETUP_NEEDED
+ session[:setup_needed] = true
+ unless o_id = session[:openid_param]
+ raise('Required values missing.')
+ end
+
+ goto = req.script_name+
+ '?'+@options[:openid_param]+
+ '='+o_id
+ body << "Reauthentication required.\n"
+ end
+ body << oid.message if oid.message
+ [ 303, {'Location'=>goto}, body]
end
end
end
diff --git a/test/spec_rack_auth_openid.rb b/test/spec_rack_auth_openid.rb
new file mode 100644
index 00000000..37d97e19
--- /dev/null
+++ b/test/spec_rack_auth_openid.rb
@@ -0,0 +1,53 @@
+require 'test/spec'
+
+# requires the ruby-openid gem
+require 'rack/auth/openid'
+
+context "Rack::Auth::OpenID" do
+ OID = Rack::Auth::OpenID
+ realm = 'http://path/arf'
+ ruri = %w{arf arf/blargh}
+ auri = ruri.map{|u|'/'+u}
+ furi = auri.map{|u|'http://path'+u}
+
+ specify 'realm uri should be absolute and have a path' do
+ lambda{OID.new('/path')}.
+ should.raise ArgumentError
+ lambda{OID.new('http://path')}.
+ should.raise ArgumentError
+ lambda{OID.new('http://path/')}.
+ should.not.raise
+ lambda{OID.new('http://path/arf')}.
+ should.not.raise
+ end
+
+ specify 'uri options should be absolute' do
+ [:login_good, :login_fail, :login_quit, :return_to].each do |param|
+ ruri.each do |uri|
+ lambda{OID.new(realm, {param=>uri})}.
+ should.raise ArgumentError
+ end
+ auri.each do |uri|
+ lambda{OID.new(realm, {param=>uri})}.
+ should.raise ArgumentError
+ end
+ furi.each do |uri|
+ lambda{OID.new(realm, {param=>uri})}.
+ should.not.raise
+ end
+ end
+ end
+
+ specify 'return_to should be absolute and be under the realm' do
+ lambda{OID.new(realm, {:return_to => 'http://path'})}.
+ should.raise ArgumentError
+ lambda{OID.new(realm, {:return_to => 'http://path/'})}.
+ should.raise ArgumentError
+ lambda{OID.new(realm, {:return_to => 'http://path/arf'})}.
+ should.not.raise
+ lambda{OID.new(realm, {:return_to => 'http://path/arf/'})}.
+ should.not.raise
+ lambda{OID.new(realm, {:return_to => 'http://path/arf/blargh'})}.
+ should.not.raise
+ end
+end
diff --git a/test/spec_rack_utils.rb b/test/spec_rack_utils.rb
index 1bb2e7f7..8256e12f 100644
--- a/test/spec_rack_utils.rb
+++ b/test/spec_rack_utils.rb
@@ -76,7 +76,6 @@ context "Rack::Utils::Context" do
test_app1 = Object.new
def test_app1.context app
Rack::Utils::Context.new self, app do |env|
- p app
app.call env
end
end