# AUTHOR: blink ; blink#ruby-lang@irc.freenode.net gem 'ruby-openid', '~> 2' if defined? Gem require 'rack/request' require 'rack/utils' require 'rack/auth/abstract/handler' require 'uri' require 'openid' #gem require 'openid/extension' #gem require 'openid/store/memory' #gem module Rack class Request def openid_request @env['rack.auth.openid.request'] end def openid_response @env['rack.auth.openid.response'] end end module Auth # Rack::Auth::OpenID provides a simple method for setting up an OpenID # Consumer. 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 # Consumer functionality. # # This library strongly intends to utilize the OpenID 2.0 features of the # ruby-openid library, which provides OpenID 1.0 compatiblity. # # NOTE: Due to the amount of data that this library stores in the # session, Rack::Session::Cookie may fault. class OpenID class NoSession < RuntimeError; end class BadExtension < RuntimeError; end # Required for ruby-openid ValidStatus = [:success, :setup_needed, :cancel, :failure] # = Arguments # # The first argument is the realm, identifying the site they are trusting # with their identity. This is required, also treated as the trust_root # in OpenID 1.x exchanges. # # 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 which allows flexibility # with caveats. # # :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'. # # :store defined what OpenID Store to use for persistant # information. By default a Store::Memory will be used. # # :immediate as true will make initial requests to be of an # immediate type. This is false by default. See OpenID specification # documentation. # # :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. # # == Examples # # simple_oid = OpenID.new('http://mysite.com/') # # return_oid = OpenID.new('http://mysite.com/', { # :return_to => 'http://mysite.com/openid' # }) # # complex_oid = OpenID.new('http://mysite.com/', # :immediate => true, # :extensions => { # ::OpenID::SReg => [['email'],['nickname']] # } # ) # # = Advanced # # Most of the functionality of this library is encapsulated such that # expansion and overriding functions isn't difficult nor tricky. # Alternately, to avoid opening up singleton objects or subclassing, a # wrapper rack middleware can be composed to act upon Auth::OpenID's # responses. See #check and #finish for locations of pertinent data. # # == Responses # # To change the responses that Auth::OpenID returns, override the methods # #redirect, #bad_request, #unauthorized, #access_denied, and # #foreign_server_failure. # # Additionally #confirm_post_params is used when the URI would exceed # length limits on a GET request when doing the initial verification # request. # # == Processing # # To change methods of processing completed transactions, override the # methods #success, #setup_needed, #cancel, and #failure. Please ensure # the returned object is a rack compatible response. # # The first argument is an OpenID::Response, the second is a # Rack::Request of the current request, the last is the hash used in # ruby-openid handling, which can be found manually at # env['rack.session'][:openid]. # # This is useful if you wanted to expand the processing done, such as # setting up user accounts. # # oid_app = Rack::Auth::OpenID.new realm, :return_to => return_to # def oid_app.success oid, request, session # user = Models::User[oid.identity_url] # user ||= Models::User.create_from_openid oid # request['rack.session'][:user] = user.id # redirect MyApp.site_home # end # # site_map['/openid'] = oid_app # map = Rack::URLMap.new site_map # ... def initialize(realm, options={}) realm = URI(realm) raise ArgumentError, "Invalid realm: #{realm}" \ unless realm.absolute? \ and realm.fragment.nil? \ and realm.scheme =~ /^https?$/ \ and realm.host =~ /^(\*\.)?#{URI::REGEXP::PATTERN::URIC_NO_SLASH}+/ realm.path = '/' if realm.path.empty? @realm = realm.to_s if ruri = options[:return_to] ruri = URI(ruri) raise ArgumentError, "Invalid return_to: #{ruri}" \ unless ruri.absolute? \ and ruri.scheme =~ /^https?$/ \ and ruri.fragment.nil? raise ArgumentError, "return_to #{ruri} not within realm #{realm}" \ unless self.within_realm?(ruri) @return_to = ruri.to_s end @session_key = options[:session_key] || 'rack.session' @openid_param = options[:openid_param] || 'openid_identifier' @store = options[:store] || ::OpenID::Store::Memory.new @immediate = !!options[:immediate] @extensions = {} if extensions = options.delete(:extensions) extensions.each do |ext, args| add_extension ext, *args end end # Undocumented, semi-experimental @anonymous = !!options[:anonymous] end attr_reader :realm, :return_to, :session_key, :openid_param, :store, :immediate, :extensions # Sets up and uses session data at :openid within the session. # Errors in this setup will raise a NoSession exception. # # If the parameter 'openid.mode' is set, which implies a followup from # the openid server, processing is passed to #finish and the result is # returned. However, if there is no appropriate openid information in the # session, a 400 error is returned. # # If the parameter specified by options[:openid_param] is # present, processing is passed to #check and the result is returned. # # If neither of these conditions are met, #unauthorized is called. def call(env) env['rack.auth.openid'] = self env_session = env[@session_key] unless env_session and env_session.is_a?(Hash) raise NoSession, 'No compatible session' end # let us work in our own namespace... session = (env_session[:openid] ||= {}) unless session and session.is_a?(Hash) raise NoSession, 'Incompatible openid session' end request = Rack::Request.new(env) consumer = ::OpenID::Consumer.new(session, @store) if mode = request.GET['openid.mode'] if session.key?(:openid_param) finish(consumer, session, request) else bad_request end elsif request.GET[@openid_param] check(consumer, session, request) else unauthorized end end # As the first part of OpenID consumer action, #check retrieves the data # required for completion. # # If all parameters fit within the max length of a URI, a 303 redirect # will be returned. Otherwise #confirm_post_params will be called. # # Any messages from OpenID's request are logged to env['rack.errors'] # # env['rack.auth.openid.request'] is the openid checkid request # instance. # # session[:openid_param] is set to the openid identifier # provided by the user. # # session[:return_to] is set to the return_to uri given to the # identity provider. def check(consumer, session, req) oid = consumer.begin(req.GET[@openid_param], @anonymous) req.env['rack.auth.openid.request'] = oid req.env['rack.errors'].puts(oid.message) p oid if $DEBUG ## Extension support extensions.each do |ext,args| oid.add_extension(ext::Request.new(*args)) end session[:openid_param] = req.GET[openid_param] return_to_uri = return_to ? return_to : req.url session[:return_to] = return_to_uri immediate = session.key?(:setup_needed) ? false : immediate if oid.send_redirect?(realm, return_to_uri, immediate) uri = oid.redirect_url(realm, return_to_uri, immediate) redirect(uri) else confirm_post_params(oid, realm, return_to_uri, immediate) end rescue ::OpenID::DiscoveryFailure => e # thrown from inside OpenID::Consumer#begin by yadis stuff req.env['rack.errors'].puts([e.message, *e.backtrace]*"\n") return foreign_server_failure end # This is the final portion of authentication. # If successful, a redirect to the realm is be returned. # Data gathered from extensions are stored in session[:openid] with the # extension's namespace uri as the key. # # Any messages from OpenID's response are logged to env['rack.errors'] # # env['rack.auth.openid.response'] will contain the openid # response. def finish(consumer, session, req) oid = consumer.complete(req.GET, req.url) req.env['rack.auth.openid.response'] = oid req.env['rack.errors'].puts(oid.message) p oid if $DEBUG raise unless ValidStatus.include?(oid.status) __send__(oid.status, oid, req, session) end # The first argument should be the main extension module. # The extension module should contain the constants: # * class Request, should have OpenID::Extension as an ancestor # * class Response, should have OpenID::Extension as an ancestor # * string NS_URI, which defining the namespace of the extension # # 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) raise BadExtension unless valid_extension?(ext) extensions[ext] = args return ext::NS_URI end # Checks the validitity, in the context of usage, of a submitted # extension. def valid_extension?(ext) if not %w[NS_URI Request Response].all?{|c| ext.const_defined?(c) } raise ArgumentError, 'Extension is missing constants.' elsif not ext::Response.respond_to?(:from_success_response) raise ArgumentError, 'Response is missing required method.' end return true rescue return false end # Checks the provided uri to ensure it'd be considered within the realm. # is currently not compatible with wildcard realms. def within_realm? uri uri = URI.parse(uri.to_s) realm = URI.parse(self.realm) return false unless uri.absolute? return false unless uri.path[0, realm.path.size] == realm.path return false unless uri.host == realm.host or realm.host[/^\*\./] # for wildcard support, is awkward with URI limitations realm_match = Regexp.escape(realm.host). sub(/^\*\./,"^#{URI::REGEXP::PATTERN::URIC_NO_SLASH}+.")+'$' return false unless uri.host.match(realm_match) return true end alias_method :include?, :within_realm? protected ### These methods define some of the boilerplate responses. # Returns an html form page for posting to an Identity Provider if the # GET request would exceed the upper URI length limit. def confirm_post_params(oid, realm, return_to, immediate) Rack::Response.new.finish do |r| r.write 'Confirm...' r.write oid.form_markup(realm, return_to, immediate) r.write '' end end # Returns a 303 redirect with the destination of that provided by the # argument. def redirect(uri) [ 303, {'Content-Length'=>'0', 'Content-Type'=>'text/plain', 'Location' => uri}, [] ] end # Returns an empty 400 response. def bad_request [ 400, {'Content-Type'=>'text/plain', 'Content-Length'=>'0'}, [''] ] end # Returns a basic unauthorized 401 response. def unauthorized [ 401, {'Content-Type' => 'text/plain', 'Content-Length' => '13'}, ['Unauthorized.'] ] end # Returns a basic access denied 403 response. def access_denied [ 403, {'Content-Type' => 'text/plain', 'Content-Length' => '14'}, ['Access denied.'] ] end # Returns a 503 response to be used if communication with the remote # OpenID server fails. def foreign_server_failure [ 503, {'Content-Type'=>'text/plain', 'Content-Length' => '23'}, ['Foreign server failure.'] ] end private ### These methods are called after a transaction is completed, depending # on its outcome. These should all return a rack compatible response. # You'd want to override these to provide additional functionality. # Called to complete processing on a successful transaction. # Within the openid session, :openid_identity and :openid_identifier are # set to the user friendly and the standard representation of the # validated identity. All other data in the openid session is cleared. def success(oid, request, session) session.clear session[:openid_identity] = oid.display_identifier session[:openid_identifier] = oid.identity_url extensions.keys.each do |ext| label = ext.name[/[^:]+$/].downcase response = ext::Response.from_success_response(oid) session[label] = response.data end redirect(realm) end # Called if the Identity Provider indicates further setup by the user is # required. # The identifier is retrived from the openid session at :openid_param. # And :setup_needed is set to true to prevent looping. def setup_needed(oid, request, session) identifier = session[:openid_param] session[:setup_needed] = true redirect req.script_name + '?' + openid_param + '=' + identifier end # Called if the user indicates they wish to cancel identification. # Data within openid session is cleared. def cancel(oid, request, session) session.clear access_denied end # Called if the Identity Provider indicates the user is unable to confirm # their identity. Data within the openid session is left alone, in case # of swarm auth attacks. def failure(oid, request, session) unauthorized end end # A class developed out of the request to use OpenID as an authentication # middleware. The request will be sent to the OpenID instance unless the # block evaluates to true. For example in rackup, you can use it as such: # # use Rack::Session::Pool # use Rack::Auth::OpenIDAuth, realm, openid_options do |env| # env['rack.session'][:authkey] == a_string # end # run RackApp # # Or simply: # # app = Rack::Auth::OpenIDAuth.new app, realm, openid_options, &auth class OpenIDAuth < Rack::Auth::AbstractHandler attr_reader :oid def initialize(app, realm, options={}, &auth) @oid = OpenID.new(realm, options) super(app, &auth) end def call(env) to = auth.call(env) ? @app : @oid to.call env end end end end