summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorScytrin dai Kinthra <scytrin@gmail.com>2008-06-23 04:25:10 -0700
committerScytrin dai Kinthra <scytrin@gmail.com>2008-06-23 04:25:10 -0700
commitf3c17573a64baaa381cd11795a3dad532a775665 (patch)
treeaa46fbcb1ebce4506b4f0096302f35760cc9549d
parent5134bcdd5cd603fe61a1b93a9281e2611c2edb71 (diff)
parent4420448857b538675b85bef5fb623fc5c14699c6 (diff)
downloadrack-f3c17573a64baaa381cd11795a3dad532a775665.tar.gz
Merge branch 'openid2'
-rw-r--r--lib/rack.rb1
-rw-r--r--lib/rack/auth/openid2.rb335
-rw-r--r--test/spec_rack_auth_openid2.rb53
3 files changed, 389 insertions, 0 deletions
diff --git a/lib/rack.rb b/lib/rack.rb
index 607d0f5c..9c9bef97 100644
--- a/lib/rack.rb
+++ b/lib/rack.rb
@@ -53,6 +53,7 @@ module Rack
autoload :AbstractRequest, "rack/auth/abstract/request"
autoload :AbstractHandler, "rack/auth/abstract/handler"
autoload :OpenID, "rack/auth/openid"
+ autoload :OpenID2, "rack/auth/openid2"
module Digest
autoload :MD5, "rack/auth/digest/md5"
autoload :Nonce, "rack/auth/digest/nonce"
diff --git a/lib/rack/auth/openid2.rb b/lib/rack/auth/openid2.rb
new file mode 100644
index 00000000..c59e93fa
--- /dev/null
+++ b/lib/rack/auth/openid2.rb
@@ -0,0 +1,335 @@
+# AUTHOR: blink <blinketje@gmail.com>; blink#ruby-lang@irc.freenode.net
+
+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 library from
+ # janrain to operate, as well as a rack method of session management.
+ #
+ # The ruby-openid home page is at <http://openidenabled.com/ruby-openid/>.
+ #
+ # The OpenID specifications can be found at
+ # Mhttp://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/>.
+ #
+ # 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.
+ #
+ # 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 and 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 OpenID2 < AbstractHandler
+ class NoSession < RuntimeError; end
+ # Required for ruby-openid
+ 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. For example:
+ #
+ # simple_oid = OpenID2.new('http://mysite.com/')
+ #
+ # return_oid = OpenID2.new('http://mysite.com/', {
+ # :return_to => 'http://mysite.com/openid'
+ # })
+ #
+ # page_oid = OpenID2.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
+ #
+ # :return_to 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 :return_to is not provided, the url
+ # will be derived within the ruby-openid implementation.
+ #
+ # :session_key defines the key to the session hash in the env. It
+ # defaults to 'rack.session'.
+ #
+ # :openid_param 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'.
+ #
+ # :extensions will specify what extensions are to used with OpenID,
+ # of which the format and support of which is yet to be completed.
+ #
+ # :immediate as true will make immediate type of requests the default.
+ # See the specification documentation.
+ #
+ # -- URL options
+ #
+ # :login_good is the url to go to after the authentication process
+ # has completed.
+ #
+ # :login_fail is the url to go to after the authentication process
+ # has failed.
+ #
+ # :login_fail is the url to go to after the authentication process
+ # has been cancelled.
+ #
+ # -- Response options
+ #
+ # :no_session should be a rack response to be returned if no or an
+ # incompatible session is found.
+ #
+ # :auth_fail 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.
+ #
+ # :error should be a rack response to return if any other generic error
+ # would occur AND options[:catch_errors] 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::OpenID2"
+ 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 :openid within the session. It
+ # sets up the ::OpenID::Consumer using the store specified by
+ # options[:store].
+ #
+ # If the parameter specified by options[:openid_param] 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 options[:catch_errors] 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
+ consumer = ::OpenID::Consumer.new session, @options[:store]
+
+ 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
+ rescue NoSession
+ env['rack.errors'].puts($!.message, *$@)
+
+ @options. ### Missing or incompatible session
+ fetch :no_session, [ 500,
+ {'Content-Type'=>'text/plain'},
+ $!.message ]
+ rescue
+ env['rack.errors'].puts($!.message, *$@)
+
+ if not @options[:catch_error]
+ raise($!)
+ end
+ @options.
+ fetch :error, [ 500,
+ {'Content-Type'=>'text/plain'},
+ 'OpenID has encountered an error.' ]
+ end
+
+ # As the first part of OpenID consumer action, #check retrieves the
+ # data required for completion.
+ #
+ # * session[:openid][:openid_param] is the request parameter requested to
+ # be authenticated.
+ # * session[:openid][:site_return] is set as the request's HTTP_REFERER
+ # if previously unset.
+ # * env['rack.auth.openid.request'] is the openid checkidrequest.
+ 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
+
+ # 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
+ # session[:openid][:site_return]. If session[:openid][:site_return] is
+ # unset, the realm will be used.
+ #
+ # Any messages from OpenID's response are appended to the 303 response
+ # body.
+ #
+ # * env['rack.auth.openid.response'] is the openid response.
+ #
+ # The four valid possible outcomes are:
+ # * failure: options[:login_fail] or session[:site_return] or the realm
+ # * session[:openid] is cleared and any messages are send to rack.errors
+ # * session[:openid]['authenticated'] is false
+ # * success: options[:login_good] or session[:site_return] or the relam
+ # * session[:openid] is cleared
+ # * session[:openid]['authenticated'] is true
+ # * session[:openid]['identity'] is the actual identifier
+ # * session[:openid]['identifier'] is the pretty identifier
+ # * cancel: options[:login_quit] or session[:site_return] or the realm
+ # * session[:openid] is cleared
+ # * session[:openid]['authenticated'] is false
+ # * setup_needed: resubmits the authentication request. A flag is set
+ # for non-immediate handling.
+ # * session[:openid][:setup_needed] is set to true, 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
+end
diff --git a/test/spec_rack_auth_openid2.rb b/test/spec_rack_auth_openid2.rb
new file mode 100644
index 00000000..2fd361ac
--- /dev/null
+++ b/test/spec_rack_auth_openid2.rb
@@ -0,0 +1,53 @@
+require 'test/spec'
+
+# requires the ruby-openid gem
+require 'rack/auth/openid2'
+
+context "Rack::Auth::OpenID2" do
+ OID2 = Rack::Auth::OpenID2
+ 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{OID2.new('/path')}.
+ should.raise ArgumentError
+ lambda{OID2.new('http://path')}.
+ should.raise ArgumentError
+ lambda{OID2.new('http://path/')}.
+ should.not.raise
+ lambda{OID2.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{OID2.new(realm, {param=>uri})}.
+ should.raise ArgumentError
+ end
+ auri.each do |uri|
+ lambda{OID2.new(realm, {param=>uri})}.
+ should.raise ArgumentError
+ end
+ furi.each do |uri|
+ lambda{OID2.new(realm, {param=>uri})}.
+ should.not.raise
+ end
+ end
+ end
+
+ specify 'return_to should be absolute and be under the realm' do
+ lambda{OID2.new(realm, {:return_to => 'http://path'})}.
+ should.raise ArgumentError
+ lambda{OID2.new(realm, {:return_to => 'http://path/'})}.
+ should.raise ArgumentError
+ lambda{OID2.new(realm, {:return_to => 'http://path/arf'})}.
+ should.not.raise
+ lambda{OID2.new(realm, {:return_to => 'http://path/arf/'})}.
+ should.not.raise
+ lambda{OID2.new(realm, {:return_to => 'http://path/arf/blargh'})}.
+ should.not.raise
+ end
+end