File: digest.rb

package info (click to toggle)
ruby-httpauth 0.2.1%2Bgh-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 192 kB
  • sloc: ruby: 925; makefile: 2
file content (618 lines) | stat: -rw-r--r-- 26,070 bytes parent folder | download | duplicates (3)
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
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
%w(tmpdir digest/md5 base64 httpauth/exceptions httpauth/constants).each { |l| require l }

module HTTPAuth
  # = Digest
  #
  # The Digest class provides a number of methods to handle HTTP Digest Authentication. Generally the server
  # sends a challenge to the client a resource that needs authorization and the client tries to respond with
  # the correct credentials. Digest authentication rapidly becomes more complicated after that, if you want to
  # build an implementation I suggest you at least skim RFC 2617 (http://www.ietf.org/rfc/rfc2617.txt).
  #
  # == Examples
  #
  # Digest authentication examples are too large to include in source documentation. Please consult the examples
  # directory for client and server implementations.
  #
  # The classes and code of the library are set up to be as transparent as possible so integrating the library
  # with any implementation talking HTTP, either trough CGI or directly should be possible.
  #
  # == The 'Digest'
  #
  # In Digest authentication the client's credentials are never sent in plain text over HTTP. You don't even have
  # to store the passwords in plain text on the server to authenticate clients. The library doesn't force you to
  # use the digest mechanism, it also works by specifying the username, password and realm. If you do decided to
  # use digests you can generate them in the following way:
  #
  #   H(username + ':' + realm + ':' + password)
  #
  # Where H returns the MD5 hexdigest of the string. The Utils class defines a method to calculate the digest.
  #
  #   HTTPAuth::Digest::Utils.htdigest(username, realm, password)
  #
  # The format of this digest is the same in most implementations. Apache's <tt>htdigest</tt> tool for instance
  # stores the digests in a textfile like this:
  #
  #   username:realm:digest
  #
  # == Security
  #
  # Digest authentication is quite a bit more secure than Basic authentication, but it isn't as secure as SSL.
  # The biggest difference between Basic and Digest authentication is that Digest authentication doesn't send
  # clear text passwords, but only an MD5 digest. Recent developments in password cracking and mathematics have
  # found several ways to create collisions with MD5 hashes and it's not infinitely secure. However, it currently
  # still takes a lot of computing power to crack MD5 digests. Checking for brute force attacks in your applications
  # and routinely changing the user credentials and maybe even the realm makes it a lot harder for a cracker to
  # abuse your application.
  module Digest
    # Utils contains all sort of conveniance methods for the header container classes. Implementations shouldn't have
    # to call any methods on Utils.
    class Utils
      class << self
        # Encodes a hash with digest directives to send in a header.
        #
        # * <tt>h</tt>: The directives specified in a hash
        # * <tt>variant</tt>: Specifies whether the directives are for an Authorize header (:credentials),
        #   for a WWW-Authenticate header (:challenge) or for a Authentication-Info header (:auth_info).
        def encode_directives(h, variant)
          encode = {:domain => :list_to_space_quoted_string, :algorithm => false, :stale => :bool_to_str, :nc => :int_to_hex}
          if [:credentials, :auth].include? variant
            encode.merge! :qop => false
          elsif variant == :challenge
            encode.merge! :qop => :list_to_comma_quoted_string
          else
            fail(ArgumentError, "#{variant} is not a valid value for `variant' use :auth, :credentials or :challenge")
          end
          (variant == :auth ? '' : 'Digest ') + h.collect do |directive, value|
            '' << directive.to_s << '=' << if encode[directive]
              begin
                Conversions.send encode[directive], value
              rescue NoMethodError, ArgumentError
                raise(ArgumentError, "Can't encode #{directive}(#{value.inspect}) with #{encode[directive]}")
              end
            elsif encode[directive].nil?
              begin
                Conversions.quote_string value
              rescue NoMethodError, ArgumentError
                raise(ArgumentError, "Can't encode #{directive}(#{value.inspect}) with quote_string")
              end
            else
              value
            end
          end.join(', ')
        end

        # Decodes digest directives from a header. Returns a hash with directives.
        #
        # * <tt>directives</tt>: The directives
        # * <tt>variant</tt>: Specifies whether the directives are for an Authorize header (:credentials),
        #   for a WWW-Authenticate header (:challenge) or for a Authentication-Info header (:auth_info).
        def decode_directives(directives, variant)
          fail(HTTPAuth::UnwellformedHeader, "Can't decode directives which are nil") if directives.nil?
          decode = {:domain => :space_quoted_string_to_list, :algorithm => false, :stale => :str_to_bool, :nc => :hex_to_int}
          if [:credentials, :auth].include? variant
            decode.merge! :qop => false
          elsif variant == :challenge
            decode.merge! :qop => :comma_quoted_string_to_list
          else
            fail(ArgumentError, "#{variant} is not a valid value for `variant' use :auth, :credentials or :challenge")
          end

          start = 0
          unless variant == :auth
            # The first six characters are 'Digest '
            start = 6
            scheme = directives[0..6].strip
            fail(HTTPAuth::UnwellformedHeader, "Scheme should be Digest, server responded with `#{directives}'") unless scheme == 'Digest'
          end

          # The rest are the directives
          # TODO: split is ugly, I want a real parser (:
          directives[start..-1].split(',').inject({}) do |h, part|
            parts = part.split('=')
            name = parts[0].strip.intern
            value = parts[1..-1].join('=').strip

            # --- HACK
            # IE and Safari qoute qop values
            # IE also quotes algorithm values
            if variant != :challenge && [:qop, :algorithm].include?(name) && value =~ /^\"[^\"]+\"$/
              value = Conversions.unquote_string(value)
            end
            # --- END HACK

            if decode[name]
              h[name] = Conversions.send decode[name], value
            elsif decode[name].nil?
              h[name] = Conversions.unquote_string value
            else
              h[name] = value
            end
            h
          end
        end

        # Concat arguments the way it's done frequently in the Digest spec.
        #
        #   digest_concat('a', 'b') #=> "a:b"
        #   digest_concat('a', 'b', c') #=> "a:b:c"
        def digest_concat(*args)
          args.join ':'
        end

        # Calculate the MD5 hexdigest for the string data
        def digest_h(data)
          ::Digest::MD5.hexdigest data
        end

        # Calculate the KD value of a secret and data as explained in the RFC.
        def digest_kd(secret, data)
          digest_h digest_concat(secret, data)
        end

        # Calculate the Digest for the credentials
        def htdigest(username, realm, password)
          digest_h digest_concat(username, realm, password)
        end

        # Calculate the H(A1) as explain in the RFC. If h[:digest] is set, it's used instead
        # of calculating H(username ":" realm ":" password).
        def digest_a1(h, s)
          # TODO: check for known algorithm values (look out for the IE algorithm quote bug)
          if h[:algorithm] == 'MD5-sess'
            digest_h digest_concat(
              h[:digest] || htdigest(h[:username], h[:realm], h[:password]),
              h[:nonce],
              h[:cnonce]
            )
          else
            h[:digest] || htdigest(h[:username], h[:realm], h[:password])
          end
        end

        # Calculate the H(A2) for the Authorize header as explained in the RFC.
        def request_digest_a2(h)
          # TODO: check for known qop values (look out for the safari qop quote bug)
          if h[:qop] == 'auth-int'
            digest_h digest_concat(h[:method], h[:uri], digest_h(h[:request_body]))
          else
            digest_h digest_concat(h[:method], h[:uri])
          end
        end

        # Calculate the H(A2) for the Authentication-Info header as explained in the RFC.
        def response_digest_a2(h)
          if h[:qop] == 'auth-int'
            digest_h ':' + digest_concat(h[:uri], digest_h(h[:response_body]))
          else
            digest_h ':' + h[:uri]
          end
        end

        # Calculate the digest value for the directives as explained in the RFC.
        #
        # * <tt>variant</tt>: Either <tt>:request</tt> or <tt>:response</tt>, as seen from the server.
        def calculate_digest(h, s, variant)
          fail(ArgumentError, "Variant should be either :request or :response, not #{variant}") unless [:request, :response].include?(variant)
          # Compatability with RFC 2069
          if h[:qop].nil?
            digest_kd digest_a1(h, s), digest_concat(
              h[:nonce],
              send("#{variant}_digest_a2".intern, h)
            )
          else
            digest_kd digest_a1(h, s), digest_concat(
              h[:nonce],
              Conversions.int_to_hex(h[:nc]),
              h[:cnonce],
              h[:qop],
              send("#{variant}_digest_a2".intern, h)
            )
          end
        end

        # Return a hash with the keys in <tt>keys</tt> found in <tt>h</tt>.
        #
        # Example
        #
        #   filter_h_on({1=>1,2=>2}, [1]) #=> {1=>1}
        #   filter_h_on({1=>1,2=>2}, [1, 2]) #=> {1=>1,2=>2}
        def filter_h_on(h, keys)
          h.inject({}) { |a, e| keys.include?(e[0]) ? a.merge(e[0] => e[1]) : a }
        end

        # Create a nonce value of the time and a salt. The nonce is created in such a
        # way that the issuer can check the age of the nonce.
        #
        # * <tt>salt</tt>: A reasonably long passphrase known only to the issuer.
        def create_nonce(salt)
          now = Time.now
          time = now.strftime('%Y-%m-%d %H:%M:%S').to_s + ':' + now.usec.to_s
          Base64.encode64(
          digest_concat(
              time,
              digest_h(digest_concat(time, salt))
            )
          ).gsub("\n", '')[0..-3]
        end

        # Create a 32 character long opaque string with a 'random' value
        def create_opaque
          s = []
          16.times { s << rand(127).chr }
          digest_h s.join
        end
      end
    end

    # Superclass for all the header container classes
    class AbstractHeader
      # holds directives and values for digest calculation
      attr_reader :h

      # Redirects attribute messages to the internal directives
      #
      # Example:
      #
      #   class Credentials < AbstractHeader
      #     def initialize
      #       @h = { :username => 'Ben' }
      #     end
      #   end
      #
      #   c = Credentials.new
      #   c.username #=> 'Ben'
      #   c.username = 'Mary'
      #   c.username #=> 'Mary'
      def method_missing(m, *a)
        if ((m.to_s =~ /^(.*)=$/) == 0) && @h.keys.include?(Regexp.last_match[1].intern)
          @h[Regexp.last_match[1].intern] = a[0]
        elsif @h.keys.include? m
          @h[m]
        else
          fail(NameError, "undefined method `#{m}' for #{self}")
        end
      end
    end

    # The Credentials class handlers the Authorize header. The Authorize header is sent by a client who wants to
    # let the server know he has the credentials needed to access a resource.
    #
    # See the Digest module for examples
    class Credentials < AbstractHeader
      # Holds an explanation why <tt>validate</tt> returned false.
      attr_reader :reason

      # Parses the information from an Authorize header and creates a new Credentials instance with the information.
      # The options hash allows you to specify additional information.
      #
      # * <tt>authorization</tt>: The contents of the Authorize header
      # See <tt>initialize</tt> for valid options.
      def self.from_header(authorization, options = {})
        new Utils.decode_directives(authorization, :credentials), options
      end

      # Creates a new Credential instance based on a Challenge instance.
      #
      # * <tt>challenge</tt>: A Challenge instance
      # See <tt>initialize</tt> for valid options.
      def self.from_challenge(challenge, options = {})
        credentials = new challenge.h
        credentials.update_from_challenge! options
        credentials
      end

      def self.load(filename, options = {})
        h = nil
        File.open(filename, 'r') do |f|
          h = Marshal.load f
        end
        new h, options
      end

      # Create a new instance.
      #
      # * <tt>h</tt>:  A Hash with directives, normally this is filled with the directives coming from a Challenge instance.
      # * <tt>options</tt>: Used to set or override data from the Authorize header and add additional parameters.
      #   * <tt>:username</tt>: Mostly set by a client to send the username
      #   * <tt>:password</tt>: Mostly set by a client to send the password, set either this or the digest
      #   * <tt>:digest</tt>: Mostly set by a client to send a digest, set either this or the digest. For more
      #     information about digests see Digest.
      #   * <tt>:uri</tt>: Mostly set by the client to send the uri
      #   * <tt>:method</tt>: The HTTP Method used by the client to send the request, this should be an uppercase string
      #     with the name of the verb.
      def initialize(h, options = {})
        @h = h
        @h.merge! options
        session = Session.new h[:opaque], :tmpdir => options[:tmpdir]
        @s = session.load
        @reason = 'There has been no validation yet'
      end

      # Convenience method, basically an alias for <code>validate(options.merge(:password => password))</code>
      def validate_password(password, options = {})
        options[:password] = password
        validate(options)
      end

      # Convenience method, basically an alias for <code>validate(options.merge(:digest => digest))</code>
      def validate_digest(digest, options = {})
        options[:digest] = digest
        validate(options)
      end

      # Validates the credential information stored in the Credentials instance. Returns <tt>true</tt> or
      # <tt>false</tt>. You can read the ue
      #
      # * <tt>options</tt>: The extra options needed to validate the credentials. A server implementation should
      #   provide the <tt>:method</tt> and a <tt>:password</tt> or <tt>:digest</tt>.
      #   * <tt>:method</tt>: The HTTP Verb in uppercase, ie. GET or POST.
      #   * <tt>:password</tt>: The password for the sent username and realm, either a password or digest should be
      #     provided.
      #   * <tt>:digest</tt>: The digest for the specified username and realm, either a digest or password should be
      #     provided.
      def validate(options)
        ho = @h.merge(options)
        fail(ArgumentError, "You have to set the :request_body value if you want to use :qop => 'auth-int'") if @h[:qop] == 'auth-int' && ho[:request_body].nil?
        fail(ArgumentError, 'Please specify the request method :method (ie. GET)') if ho[:method].nil?

        calculated_response = Utils.calculate_digest(ho, @s, :request)
        if ho[:response] == calculated_response
          @reason = ''
          return true
        else
          @reason = "Response isn't the same as computed response #{ho[:response]} != #{calculated_response} for #{ho.inspect}"
        end
        false
      end

      # Encodeds directives and returns a string that can be used in the Authorize header
      def to_header
        Utils.encode_directives Utils.filter_h_on(@h,
                                                  [:username, :realm, :nonce, :uri, :response, :algorithm, :cnonce, :opaque, :qop, :nc]), :credentials
      end

      # Updates @h from options, generally called after an instance was created with <tt>from_challenge</tt>.
      def update_from_challenge!(options)
        # TODO: integrity checks
        @h[:username] = options[:username]
        @h[:password] = options[:password]
        @h[:digest] = options[:digest]
        @h[:uri] = options[:uri]
        @h[:method] = options[:method]
        @h[:request_body] = options[:request_body]
        unless @h[:qop].nil?
          # Determine the QOP
          if !options[:qop].nil? && @h[:qop].include?(options[:qop])
            @h[:qop] = options[:qop]
          elsif @h[:qop].include?(HTTPAuth::PREFERRED_QOP)
            @h[:qop] = HTTPAuth::PREFERRED_QOP
          else
            qop = @h[:qop].detect { |qop_field| HTTPAuth::SUPPORTED_QOPS.include? qop_field }
            if qop.nil?
              fail(UnsupportedError, "HTTPAuth doesn't support any of the proposed qop values: #{@h[:qop].inspect}")
            else
              @h[:qop] = qop
            end
          end
          @h[:cnonce] ||= Utils.create_nonce options[:salt]
          @h[:nc] ||= 1 unless @h[:qop].nil?
        end
        @h[:response] = Utils.calculate_digest(@h, @s, :request)
      end

      def dump_sans_creds(filename)
        File.open(filename, 'w') do |f|
          Marshal.dump(Utils.filter_h_on(@h, [:username, :realm, :nonce, :algorithm, :cnonce, :opaque, :qop, :nc]), f)
        end
      end
    end

    # The Challenge class handlers the WWW-Authenticate header. The WWW-Authenticate header is sent by a server when
    # accessing a resource without credentials is prohibided. The header should always be sent together with a 401
    # status.
    #
    # See the Digest module for examples
    class Challenge < AbstractHeader
      # Parses the information from a WWW-Authenticate header and creates a new WWW-Authenticate instance with this
      # data.
      #
      # * <tt>challenge</tt>: The contents of a WWW-Authenticate header
      # See <tt>initialize</tt> for valid options.
      def self.from_header(challenge, options = {})
        new Utils.decode_directives(challenge, :challenge), options
      end

      # Create a new instance.
      #
      # * <tt>h</tt>: A Hash with directives, normally this is filled with directives coming from a Challenge instance.
      # * <tt>options</tt>: Use to set of override data from the WWW-Authenticate header
      #   * <tt>:realm</tt>: The name of the realm the client should authenticate for. The RFC suggests to use a string
      #     like 'admin@yourhost.domain.com'. Be sure to use a reasonably long string to avoid brute force attacks.
      #   * <tt>:qop</tt>: A list with supported qop values. For example: <code>['auth-int']</code>. This will default
      #     to <code>['auth']</code>. Although this implementation supports both auth and auth-int, most
      #     implementations don't. Some implementations get confused when they receive anything but 'auth'. For
      #     maximum compatibility you should leave this setting alone.
      #   * <tt>:algorithm</tt>: The preferred algorithm for calculating the digest. For
      #     example: <code>'MD5-sess'</code>. This will default to <code>'MD5'</code>. For
      #     maximum compatibility you should leave this setting alone.
      #
      def initialize(h, options = {})
        @h = h
        @h.merge! options
      end

      # Encodes directives and returns a string that can be used as the WWW-Authenticate header
      def to_header
        @h[:nonce] ||= Utils.create_nonce @h[:salt]
        @h[:opaque] ||= Utils.create_opaque
        @h[:algorithm] ||= HTTPAuth::PREFERRED_ALGORITHM
        @h[:qop] ||= [HTTPAuth::PREFERRED_QOP]
        Utils.encode_directives Utils.filter_h_on(@h,
                                                  [:realm, :domain, :nonce, :opaque, :stale, :algorithm, :qop]), :challenge
      end
    end

    # The AuthenticationInfo class handles the Authentication-Info header. Sending Authentication-Info headers will
    # allow the client to check the integrity of the response, but it isn't compulsory and will get in the way of
    # pipelined retrieval of resources.
    #
    # See the Digest module for examples
    class AuthenticationInfo < AbstractHeader
      # Parses the information from a Authentication-Info header and creates a new AuthenticationInfo instance with
      # this data.
      #
      # * <tt>auth_info</tt>: The contents of the Authentication-Info header
      # See <tt>initialize</tt> for valid options.
      def self.from_header(auth_info, options = {})
        new Utils.decode_directives(auth_info, :auth), options
      end

      # Creates a new AuthenticationInfo instance based on the information from Credentials instance.
      #
      # * <tt>credentials</tt>: A Credentials instance
      # See <tt>initialize</tt> for valid options.
      def self.from_credentials(credentials, options = {})
        auth_info = new credentials.h
        auth_info.update_from_credentials! options
        auth_info
      end

      # Create a new instance.
      #
      # * <tt>h</tt>: A Hash with directives, normally this is filled with the directives coming from a
      #   Credentials instance.
      # * <tt>options</tt>: Used to set or override data from the Authentication-Info header
      #   * <tt>:digest</tt>: The digest for the specified username and realm.
      #   * <tt>:response_body</tt> The body of the response that's going to be sent to the client. This is a
      #     compulsory option if the qop directive is 'auth-int'.
      def initialize(h, options = {})
        @h = h
        @h.merge! options
      end

      # Encodes directives and returns a string that can be used as the AuthorizationInfo header
      def to_header
        Utils.encode_directives Utils.filter_h_on(@h,
                                                  [:nextnonce, :qop, :rspauth, :cnonce, :nc]), :auth
      end

      # Updates @h from options, generally called after an instance was created with <tt>from_credentials</tt>.
      def update_from_credentials!(options)
        # TODO: update @h after nonce invalidation
        [:digest, :username, :realm, :password].each do |k|
          @h[k] = options[k] if options.include? k
        end
        @h[:response_body] = options[:response_body]
        @h[:nextnonce] = Utils.create_nonce @h[:salt]
        @h[:rspauth] = Utils.calculate_digest(@h, nil, :response)
      end

      # Validates rspauth.  Returns <tt>true</tt> or <tt>false</tt>
      #
      # * <tt>options</tt>: The extra options needed to validate rspauth.
      #   * <tt>:digest</tt>: The H(a1) digest
      #   * <tt>:uri</tt>: request uri
      #   * <tt>:nonce</tt>:nonce
      def validate(options)
        ho = @h.merge(options)
        @h[:rspauth] == Utils.calculate_digest(ho, @s, :response)
      end
    end

    # Conversion for a number of internal data structures to and from directives in the headers. Implementations
    # shouldn't have to call any methods on Conversions.
    class Conversions
      class << self
        # Adds quotes around the string
        def quote_string(str)
          "\"#{str.gsub(/\"/, '')}\""
        end

        # Removes quotes from around a string
        def unquote_string(str)
          str =~ /^\"([^\"]*)\"$/ ? Regexp.last_match[1] : str
        end

        # Creates an int value from hex values
        def hex_to_int(str)
          "0x#{str}".hex
        end

        # Creates a hex value in a string from an integer
        def int_to_hex(i)
          i.to_s(16).rjust 8, '0'
        end

        # Creates a boolean value from a string => true or false
        def str_to_bool(str)
          str == 'true'
        end

        # Creates a string value from a boolean => 'true' or 'false'
        def bool_to_str(bool)
          bool ? 'true' : 'false'
        end

        # Creates a quoted string with space separated items from a list
        def list_to_space_quoted_string(list)
          quote_string list.join(' ')
        end

        # Creates a list from a quoted space separated string of items
        def space_quoted_string_to_list(string)
          unquote_string(string).split ' '
        end

        # Creates a quoted string with comma separated items from a list
        def list_to_comma_quoted_string(list)
          quote_string list.join(',')
        end
        # Create a list from a quoted comma separated string of items
        def comma_quoted_string_to_list(string)
          unquote_string(string).split ','
        end
      end
    end

    # Session is a file-based session implementation for storing details about the Digest authentication session
    # between requests.
    class Session
      attr_accessor :opaque
      attr_accessor :options

      # Initializes the new Session object.
      #
      # * <tt>opaque</tt> - A string to identify the session. This would normally be the <tt>opaque</tt> sent by the
      #   client, but it could also be an identifier sent through a different mechanism.
      # * <tt>options</tt> - Additional options
      #   * <tt>:tmpdir</tt> A tempory directory for storing the session data. Dir::tmpdir is the default.
      def initialize(opaque, options = {})
        self.opaque = opaque
        self.options = options
      end

      # Associates the new data to the session and removes the old
      def save(data)
        File.open(filename, 'w') do |f|
          f.write Marshal.dump(data)
        end
      end

      # Returns the data from this session
      def load
        File.open(filename, 'r') do |f|
          Marshal.load f.read
        end
      rescue Errno::ENOENT
        {}
      end

    protected

      # The filename from which the session will be saved and read from
      def filename
        "#{options[:tmpdir] || Dir.tmpdir}/ruby_digest_cache.#{opaque}"
      end
    end
  end
end