aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_dispatch/vendor/rack-1.0/rack/auth/openid.rb
blob: c5f6a5143e328f46f57c6d25dc2fda131c9ecba6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
# AUTHOR: blink <blinketje@gmail.com>; 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
      #
      # <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, return_to will be the current url which allows flexibility
      # with caveats.
      #
      # <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>:store</tt> defined what OpenID Store to use for persistant
      # information. By default a Store::Memory will be used.
      #
      # <tt>:immediate</tt> as true will make initial requests to be of an
      # immediate type. This is false by default. See OpenID specification
      # documentation.
      #
      # <tt>:extensions</tt> 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 <tt>:openid</tt> 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 <tt>options[:openid_param]</tt> 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']
      #
      # <tt>env['rack.auth.openid.request']</tt> is the openid checkid request
      # instance.
      #
      # <tt>session[:openid_param]</tt> is set to the openid identifier
      # provided by the user.
      #
      # <tt>session[:return_to]</tt> 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']
      #
      # <tt>env['rack.auth.openid.response']</tt> 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 '<html><head><title>Confirm...</title></head><body>'
          r.write oid.form_markup(realm, return_to, immediate)
          r.write '</body></html>'
        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