# AUTHOR: blink ; 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/extension' #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 # 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/. # # 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 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::Store::Memory.new HTML = '%s%s' # A Hash of options is taken as it's single initializing # 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' # ) # # complex_oid = OpenID.new('http://mysite.com/', # :return_to => 'http://mysite.com/openid', # :login_good => 'http://mysite.com/user/preferences', # :auth_fail => [500, {'Content-Type'=>'text/plain'}, # 'Unable to negotiate with foreign server.'], # :immediate => true, # :extensions => { # ::OpenID::SReg => [['email'],['nickname']] # } # ) # # = 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, :return_to will be the current url including all query # parameters. # # :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'. # # :immediate as true will make immediate type of requests the # default. See OpenID 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_quit 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. # # === Extensions # # :extensions should be a hash of openid extension # implementations. The key should be the extension main module, the value # should be an array of arguments for extension::Request.new # # The hash is iterated over and passed to #add_extension for processing. # Please see #add_extension for further documentation. 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 extensions = options.delete(:extensions) extensions.each do |ext, args| add_extension ext, *args end 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) @extensions = {} end attr_reader :options, :extensions # 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['openid.mode'] finish consumer, session, request elsif request.params[@options[:openid_param]] check 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 set to the submitted # identifier to be authenticated. # * session[:openid][:site_return] is set as the request's # HTTP_REFERER, unless already set. # * env['rack.auth.openid.request'] is the openid checkid # request instance. 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[1] ||= req.url query_args[2] = false if session.key? :setup_needed pp query_args if $DEBUG ## Extension support extensions.each do |ext,args| oid.add_extension ext::Request.new(*args) end if oid.send_redirect?(*query_args) redirect = oid.redirect_url(*query_args) if $DEBUG pp redirect pp Rack::Utils.parse_query(URI(redirect).query) end [ 303, {'Location'=>redirect}, [] ] else # check on 'action' option. formbody = oid.form_markup(*query_args) if $DEBUG pp formbody end 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. # # Data gathered from extensions are stored in session[:openid] with the # extension's namespace uri as the key. # # * 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 realm # * 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_good] 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 @options.key? :login_fail body << "Authentication unsuccessful.\n" when ::OpenID::Consumer::SUCCESS session.clear ## Extension support extensions.each do |ext, args| session[ext::NS_URI] = ext::Response. from_success_response(oid). get_extension_args end 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 @options.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 # The first argument should be the main extension module. # The extension module should contain the constants: # * class Request, with OpenID::Extension as an ancestor # * class Response, with OpenID::Extension as an ancestor # * string NS_URI, which defines the namespace of the extension, should # be an absolute http uri # # All trailing arguments will be passed to extension::Request.new in # #check. # The openid response will be passed to # extension::Response#from_success_response, #get_extension_args will be # called on the result to attain the gathered data. # # This method returns the key at which the response data will be found in # the session, which is the namespace uri by default. def add_extension ext, *args if not ext.is_a? Module raise TypeError, "#{ext.inspect} is not a module" elsif !(m = %w'Request Response NS_URI' - ext.constants.map{ |c| c.to_s }).empty? raise ArgumentError, "#{ext.inspect} missing #{m*', '}" end consts = [ext::Request, ext::Response] if not consts.all?{|c| c.is_a? Class } raise TypeError, "#{ext.inspect}'s Request or Response is not a class" elsif not consts.all?{|c| ::OpenID::Extension > c } raise ArgumentError, "#{ext.inspect}'s Request or Response not a decendant of OpenID::Extension" end if not ext::NS_URI.is_a? String raise TypeError, "#{ext.inspect}'s NS_URI is not a string" elsif not uri = URI(ext::NS_URI) raise ArgumentError, "#{ext.inspect}'s NS_URI is not a valid uri" elsif not uri.scheme =~ /^https?$/ raise ArgumentError, "#{ext.inspect}'s NS_URI is not an http uri" elsif not uri.absolute? raise ArgumentError, "#{ext.inspect}'s NS_URI is not and absolute uri" end @extensions[ext] = args return ext::NS_URI end # A conveniance method that returns the namespace of all current # extensions used by this instance. def extension_namespaces @extensions.keys.map{|e|e::NS_URI} end end end end