oauthenticator 0.1.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ module OAuthenticator
2
+ # OAuthenticator::Error represents some problem with authenticating. it has an #errors attribute with error
3
+ # messages in the form we use.
4
+ class Error < StandardError
5
+ # @param message [String]
6
+ # @param errors [Hash<String, Array<String>>]
7
+ def initialize(message, errors=nil)
8
+ super(message)
9
+ @errors = errors
10
+ end
11
+
12
+ # @return [Hash<String, Array<String>>]
13
+ def errors
14
+ @errors ||= Hash.new { |h,k| h[k] = [] }
15
+ end
16
+ end
17
+
18
+ # an error parsing an authorization header in .parse_authorization
19
+ class ParseError < Error; end
20
+
21
+ # an error indicating duplicated paramaters present in an authorization header, in violation of section 3.1
22
+ # ("Each parameter MUST NOT appear more than once per request.") and 3.2 ("The server SHOULD return a 400
23
+ # (Bad Request) status code when receiving a request with unsupported parameters, an unsupported signature
24
+ # method, missing parameters, or duplicated protocol parameters.")
25
+ class DuplicatedParameters < Error; end
26
+
27
+ class << self
28
+ # @param header [String] an Authorization header
29
+ # @return [Hash<String, String>] parsed authorization parameters
30
+ # @raise [OAuthenticator::ParseError] if the header is not well-formed and cannot be parsed
31
+ # @raise [OAuthenticator::DuplicatedParameters] if the header contains multiple instances of the same param
32
+ def parse_authorization(header)
33
+ header = header.to_s
34
+ scanner = StringScanner.new(header)
35
+ auth_parse_error = proc { |message| raise ParseError.new(message, {'Authorization' => [message]}) }
36
+ scanner.scan(/OAuth\s*/i) || auth_parse_error.call("Authorization scheme is not OAuth - recieved: #{header}")
37
+ attributes = Hash.new { |h,k| h[k] = [] }
38
+ while match = scanner.scan(/(\w+)="([^"]*)"\s*(,?)\s*/)
39
+ key = scanner[1]
40
+ value = scanner[2]
41
+ comma_follows = !scanner[3].empty?
42
+ if !comma_follows && !scanner.eos?
43
+ auth_parse_error.call("Could not parse Authorization header: #{header}\naround or after character #{scanner.pos}: #{scanner.rest}")
44
+ end
45
+ attributes[unescape(key)] << unescape(value)
46
+ end
47
+ unless scanner.eos?
48
+ auth_parse_error.call("Could not parse Authorization header: #{header}\naround or after character #{scanner.pos}: #{scanner.rest}")
49
+ end
50
+ duplicates = attributes.select { |k,v| v.size > 1 }
51
+ if duplicates.any?
52
+ errors = duplicates.map do |k,vs|
53
+ {k => "Received multiple instances of Authorization parameter #{k}. Received values were: #{vs.inspect}"}
54
+ end.inject({}, &:update)
55
+ raise DuplicatedParameters.new("Received duplicate parameters: #{duplicates.keys.inspect}", errors)
56
+ end
57
+ return attributes.map { |k,v| {k => v.first} }.inject({}, &:update)
58
+ end
59
+
60
+ # escape a value
61
+ # @param value [String] value
62
+ # @return [String] escaped value
63
+ def escape(value)
64
+ uri_parser.escape(value.to_s, /[^a-z0-9\-\.\_\~]/i)
65
+ end
66
+
67
+ # unescape a value
68
+ # @param value [String] escaped value
69
+ # @return [String] unescaped value
70
+ def unescape(value)
71
+ uri_parser.unescape(value.to_s)
72
+ end
73
+
74
+ private
75
+
76
+ # @return [Object] a parser that responds to #escape and #unescape
77
+ def uri_parser
78
+ @uri_parser ||= URI.const_defined?(:Parser) ? URI::Parser.new : URI
79
+ end
80
+ end
81
+ end
@@ -1,5 +1,4 @@
1
1
  require 'rack'
2
- require 'simple_oauth'
3
2
  require 'json'
4
3
  require 'oauthenticator/signed_request'
5
4
 
@@ -11,16 +10,21 @@ module OAuthenticator
11
10
  # structured like rails / ActiveResource:
12
11
  #
13
12
  # {'errors': {'attribute1': ['messageA', 'messageB'], 'attribute2': ['messageC']}}
14
- class Middleware
13
+ class RackAuthenticator
15
14
  # options:
16
15
  #
17
16
  # - `:bypass` - a proc which will be called with a Rack::Request, which must have a boolean result.
18
- # if the result is true, authorization checking is bypassed. if false, the request is authenticated
17
+ # if the result is true, authentication checking is bypassed. if false, the request is authenticated
19
18
  # and responds 401 if not authenticated.
20
19
  #
21
20
  # - `:config_methods` - a Module which defines necessary methods for an {OAuthenticator::SignedRequest} to
22
21
  # determine if it is validly signed. See documentation for {OAuthenticator::ConfigMethods}
23
22
  # for details of what this module must implement.
23
+ #
24
+ # - `:logger` - a Logger instance to which OAuthenticator::RackAuthenticator will log informative messages
25
+ #
26
+ # - `:realm` - 401 responses include a `WWW-Authenticate` with the realm set to the given value. default
27
+ # is an empty string.
24
28
  def initialize(app, options={})
25
29
  @app=app
26
30
  @options = options
@@ -40,22 +44,46 @@ module OAuthenticator
40
44
  oauth_signed_request_class = OAuthenticator::SignedRequest.including_config(@options[:config_methods])
41
45
  oauth_request = oauth_signed_request_class.from_rack_request(request)
42
46
  if oauth_request.errors
43
- unauthorized_response({'errors' => oauth_request.errors})
47
+ log_unauthenticated(env, oauth_request)
48
+ unauthenticated_response({'errors' => oauth_request.errors})
44
49
  else
50
+ log_success(env, oauth_request)
45
51
  env["oauth.consumer_key"] = oauth_request.consumer_key
46
- env["oauth.access_token"] = oauth_request.token
52
+ env["oauth.token"] = oauth_request.token
47
53
  env["oauth.authenticated"] = true
48
54
  @app.call(env)
49
55
  end
50
56
  end
51
57
  end
52
58
 
53
- # the response for an unauthorized request. the argument will be a hash with the key 'errors', whose value
54
- # is a hash with string keys indicating attributes with errors, and values being arrays of strings
59
+ private
60
+
61
+ # the response for an unauthenticated request. the argument will be a hash with the key 'errors', whose
62
+ # value is a hash with string keys indicating attributes with errors, and values being arrays of strings
55
63
  # indicating error messages on the attribute key..
56
- def unauthorized_response(error_object)
57
- response_headers = {"WWW-Authenticate" => %q(OAuth realm="/"), 'Content-Type' => 'application/json'}
64
+ def unauthenticated_response(error_object)
65
+ # default to a blank realm, I suppose
66
+ realm = @options[:realm] || ''
67
+ response_headers = {"WWW-Authenticate" => %Q(OAuth realm="#{realm}"), 'Content-Type' => 'application/json'}
58
68
  [401, response_headers, [JSON.pretty_generate(error_object)]]
59
69
  end
70
+
71
+ # write a log entry regarding an unauthenticated request
72
+ def log_unauthenticated(env, oauth_request)
73
+ log :warn, "OAuthenticator rejected a request:\n" +
74
+ "\tAuthorization: #{env['HTTP_AUTHORIZATION']}\n" +
75
+ "\tErrors: #{JSON.generate(oauth_request.errors)}"
76
+ end
77
+
78
+ # write a log entry for a successfully authenticated request
79
+ def log_success(env, oauth_request)
80
+ log :info, "OAuthenticator authenticated an authentic request with Authorization: #{env['HTTP_AUTHORIZATION']}"
81
+ end
82
+
83
+ def log(level, message)
84
+ if @options[:logger]
85
+ @options[:logger].send(level, message)
86
+ end
87
+ end
60
88
  end
61
89
  end
@@ -0,0 +1,340 @@
1
+ require 'openssl'
2
+ require 'uri'
3
+ require 'base64'
4
+ require 'cgi'
5
+ require 'strscan'
6
+ require 'oauthenticator/parse_authorization'
7
+
8
+ module OAuthenticator
9
+ # a request which may be signed with OAuth, generally in order to apply the signature to an outgoing request
10
+ # in the Authorization header.
11
+ #
12
+ # primarily this is to be used like:
13
+ #
14
+ # oauthenticator_signable_request = OAuthenticator::SignableRequest.new(
15
+ # :request_method => my_request_method,
16
+ # :uri => my_request_uri,
17
+ # :media_type => my_request_media_type,
18
+ # :body => my_request_body,
19
+ # :signature_method => my_oauth_signature_method,
20
+ # :consumer_key => my_oauth_consumer_key,
21
+ # :consumer_secret => my_oauth_consumer_secret,
22
+ # :token => my_oauth_token,
23
+ # :token_secret => my_oauth_token_secret,
24
+ # :realm => my_authorization_realm
25
+ # )
26
+ # my_http_request.headers['Authorization'] = oauthenticator_signable_request.authorization
27
+ class SignableRequest
28
+ # keys of OAuth protocol parameters which form the Authorization header (with an oauth_ prefix).
29
+ # signature is considered separately.
30
+ PROTOCOL_PARAM_KEYS = %w(consumer_key token signature_method timestamp nonce version).map(&:freeze).freeze
31
+
32
+ # initialize a signable request with the following attributes (keys may be string or symbol):
33
+ #
34
+ # - request_method (required) - get, post, etc. may be string or symbol.
35
+ # - uri (required) - request URI. to_s is called so URI or Addressable::URI or whatever may be passed.
36
+ # - media_type (required) - the request media type (may be nil if there is no body). note that this may be
37
+ # different than the Content-Type header; other components of that such as encoding must not be included.
38
+ # - body (required) - the request body. may be a String or an IO, or nil if no body is present.
39
+ # - hash_body? - whether to add the oauth_body_hash parameter, per the OAuth Request Body Hash
40
+ # specification. defaults to true. not used if the 'authorization' parameter is used.
41
+ # - signature_method (required*) - oauth signature method (String)
42
+ # - consumer_key (required*) - oauth consumer key (String)
43
+ # - consumer_secret (required*) - oauth consumer secret (String)
44
+ # - token (optional*) - oauth token; may be omitted if only using a consumer key (two-legged)
45
+ # - token_secret (optional) - must be present if token is present. must be omitted if token is omitted.
46
+ # - timestamp (optional*) - if omitted, defaults to the current time.
47
+ # if nil is passed, no oauth_timestamp will be present in the generated authorization.
48
+ # - nonce (optional*) - if omitted, defaults to a random string.
49
+ # if nil is passed, no oauth_nonce will be present in the generated authorization.
50
+ # - version (optional*) - must be nil or '1.0'. defaults to '1.0' if omitted.
51
+ # if nil is passed, no oauth_version will be present in the generated authorization.
52
+ # - realm (optional) - authorization realm.
53
+ # if nil is passed, no realm will be present in the generated authorization.
54
+ # - authorization - a hash of a received Authorization header, the result of a call to
55
+ # OAuthenticator.parse_authorization. it is useful for calculating the signature of a received request,
56
+ # but for fully authenticating a received request it is generally preferable to use
57
+ # OAuthenticator::SignedRequest. specifying this precludes the requirement to specify any of
58
+ # PROTOCOL_PARAM_KEYS.
59
+ #
60
+ # (*) attributes which are in PROTOCOL_PARAM_KEYS are unused (and not required) when the
61
+ # 'authorization' attribute is given for signature verification. normally, though, they are used and
62
+ # are required or optional as noted.
63
+ def initialize(attributes)
64
+ raise TypeError, "attributes must be a hash" unless attributes.is_a?(Hash)
65
+ # stringify symbol keys
66
+ @attributes = attributes.map { |k,v| {k.is_a?(Symbol) ? k.to_s : k => v} }.inject({}, &:update)
67
+
68
+ # validation - presence
69
+ required = %w(request_method uri media_type body)
70
+ required += %w(signature_method consumer_key) unless @attributes['authorization']
71
+ missing = required - @attributes.keys
72
+ raise ArgumentError, "missing: #{missing.inspect}" if missing.any?
73
+ other_recognized = PROTOCOL_PARAM_KEYS + %w(authorization consumer_secret token_secret realm hash_body?)
74
+ extra = @attributes.keys - (required + other_recognized)
75
+ raise ArgumentError, "received unrecognized @attributes: #{extra.inspect}" if extra.any?
76
+
77
+ if @attributes['authorization']
78
+ # this means we are signing an existing request to validate the received signature. don't use defaults.
79
+ unless @attributes['authorization'].is_a?(Hash)
80
+ raise TypeError, "authorization must be a Hash"
81
+ end
82
+
83
+ # if authorization is specified, protocol params should not be specified in the regular attributes
84
+ given_protocol_params = @attributes.reject { |k,v| !(PROTOCOL_PARAM_KEYS.include?(k) && v) }
85
+ if given_protocol_params.any?
86
+ raise ArgumentError, "an existing authorization was given, but protocol parameters were also " +
87
+ "given. protocol parameters should not be specified when verifying an existing authorization. " +
88
+ "given protocol parameters were: #{given_protocol_params.inspect}"
89
+ end
90
+ else
91
+ # defaults
92
+ defaults = {
93
+ 'version' => '1.0',
94
+ }
95
+ if @attributes['signature_method'] != 'PLAINTEXT'
96
+ defaults.update({
97
+ 'nonce' => OpenSSL::Random.random_bytes(16).unpack('H*')[0],
98
+ 'timestamp' => Time.now.to_i.to_s,
99
+ })
100
+ end
101
+ @attributes['authorization'] = PROTOCOL_PARAM_KEYS.map do |key|
102
+ {"oauth_#{key}" => @attributes.key?(key) ? @attributes[key] : defaults[key]}
103
+ end.inject({}, &:update).reject {|k,v| v.nil? }
104
+ @attributes['authorization']['realm'] = @attributes['realm'] unless @attributes['realm'].nil?
105
+
106
+ hash_body
107
+ end
108
+ end
109
+
110
+ # returns the Authorization header generated for this request.
111
+ #
112
+ # @return [String] Authorization header
113
+ def authorization
114
+ "OAuth #{normalized_protocol_params_string}"
115
+ end
116
+
117
+ # the oauth_signature calculated for this request.
118
+ #
119
+ # @return [String] oauth signature
120
+ def signature
121
+ rbmethod = SIGNATURE_METHODS[signature_method] ||
122
+ raise(ArgumentError, "invalid signature method: #{signature_method}")
123
+ rbmethod.bind(self).call
124
+ end
125
+
126
+ # the oauth_body_hash calculated for this request, if applicable, per the OAuth Request Body Hash
127
+ # specification.
128
+ #
129
+ # @return [String, nil] oauth body hash
130
+ def body_hash
131
+ BODY_HASH_METHODS[signature_method] ? BODY_HASH_METHODS[signature_method].bind(self).call : nil
132
+ end
133
+
134
+ # protocol params for this request as described in section 3.4.1.3
135
+ #
136
+ # signature is not calculated for this - use #signed_protocol_params to get protocol params including a
137
+ # signature.
138
+ #
139
+ # note that if this is a previously-signed request, the oauth_signature attribute returned is the
140
+ # received value, NOT the value calculated by us.
141
+ #
142
+ # @return [Hash<String, String>] protocol params
143
+ def protocol_params
144
+ @attributes['authorization'].dup
145
+ end
146
+
147
+ # protocol params for this request as described in section 3.4.1.3, including our calculated
148
+ # oauth_signature.
149
+ #
150
+ # @return [Hash<String, String>] signed protocol params
151
+ def signed_protocol_params
152
+ protocol_params.merge('oauth_signature' => signature)
153
+ end
154
+
155
+ # is the media type application/x-www-form-urlencoded
156
+ #
157
+ # @return [Boolean]
158
+ def form_encoded?
159
+ media_type = @attributes['media_type']
160
+ # media tye is case insensitive per http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
161
+ media_type = media_type.downcase if media_type.is_a?(String)
162
+ media_type == "application/x-www-form-urlencoded"
163
+ end
164
+
165
+ private
166
+
167
+ # signature base string for signing. section 3.4.1
168
+ #
169
+ # @return [String]
170
+ def signature_base
171
+ parts = [normalized_request_method, base_string_uri, normalized_request_params_string]
172
+ parts.map { |v| OAuthenticator.escape(v) }.join('&')
173
+ end
174
+
175
+ # section 3.4.1.2
176
+ #
177
+ # @return [String]
178
+ def base_string_uri
179
+ URI.parse(@attributes['uri'].to_s).tap do |uri|
180
+ uri.scheme = uri.scheme.downcase
181
+ uri.host = uri.host.downcase
182
+ uri.normalize!
183
+ uri.fragment = nil
184
+ uri.query = nil
185
+ end.to_s
186
+ end
187
+
188
+ # section 3.4.1.1
189
+ #
190
+ # @return [String]
191
+ def normalized_request_method
192
+ @attributes['request_method'].to_s.upcase
193
+ end
194
+
195
+ # section 3.4.1.3.2
196
+ #
197
+ # @return [String]
198
+ def normalized_request_params_string
199
+ normalized_request_params.map { |kv| kv.map { |v| OAuthenticator.escape(v) } }.sort.map { |p| p.join('=') }.join('&')
200
+ end
201
+
202
+ # section 3.4.1.3
203
+ #
204
+ # @return [Array<Array<String> (size 2)>]
205
+ def normalized_request_params
206
+ query_params + protocol_params.reject { |k,v| %w(realm oauth_signature).include?(k) }.to_a + entity_params
207
+ end
208
+
209
+ # section 3.4.1.3.1
210
+ #
211
+ # parsed query params, extracted from the request URI. since keys may appear multiple times, represented
212
+ # as an array of two-element arrays and not a hash
213
+ #
214
+ # @return [Array<Array<String, nil> (size 2)>]
215
+ def query_params
216
+ parse_form_encoded(URI.parse(@attributes['uri'].to_s).query || '')
217
+ end
218
+
219
+ # section 3.4.1.3.1
220
+ #
221
+ # parsed entity params from the body, when the request is form encoded. since keys may appear multiple
222
+ # times, represented as an array of two-element arrays and not a hash
223
+ #
224
+ # @return [Array<Array<String, nil> (size 2)>]
225
+ def entity_params
226
+ if form_encoded?
227
+ parse_form_encoded(read_body)
228
+ else
229
+ []
230
+ end
231
+ end
232
+
233
+ # like CGI.parse but it keeps keys without any value. doesn't keep blank keys though.
234
+ #
235
+ # @return [Array<Array<String, nil> (size 2)>]
236
+ def parse_form_encoded(data)
237
+ data.split(/[&;]/).map do |pair|
238
+ key, value = pair.split('=', 2).map { |v| CGI::unescape(v) }
239
+ [key, value] unless [nil, ''].include?(key)
240
+ end.compact
241
+ end
242
+
243
+ # string of protocol params including signature, sorted
244
+ #
245
+ # @return [String]
246
+ def normalized_protocol_params_string
247
+ signed_protocol_params.sort.map { |(k,v)| %Q(#{OAuthenticator.escape(k)}="#{OAuthenticator.escape(v)}") }.join(', ')
248
+ end
249
+
250
+ # reads the request body, be it String or IO
251
+ #
252
+ # @return [String] request body
253
+ def read_body
254
+ body = @attributes['body']
255
+ if body.nil?
256
+ ''
257
+ elsif body.is_a?(String)
258
+ body
259
+ elsif body.respond_to?(:read) && body.respond_to?(:rewind)
260
+ body.rewind
261
+ body.read.tap do
262
+ body.rewind
263
+ end
264
+ else
265
+ raise TypeError, "Body must be a String or something IO-like (responding to #read and #rewind). " +
266
+ "got body = #{body.inspect}"
267
+ end
268
+ end
269
+
270
+ # set the oauth_body_hash to the hash of the request body
271
+ #
272
+ # @return [Void]
273
+ def hash_body
274
+ if hash_body?
275
+ @attributes['authorization']['oauth_body_hash'] = body_hash
276
+ end
277
+ end
278
+
279
+ # whether we will hash the body, per oauth request body hash section 4.1, as well as whether the caller
280
+ # said to
281
+ #
282
+ # @return [Boolean]
283
+ def hash_body?
284
+ BODY_HASH_METHODS[signature_method] && !form_encoded? &&
285
+ (@attributes.key?('hash_body?') ? @attributes['hash_body?'] : true)
286
+ end
287
+
288
+ # signature method
289
+ #
290
+ # @return [String]
291
+ def signature_method
292
+ @attributes['authorization']['oauth_signature_method']
293
+ end
294
+
295
+ # signature, with method RSA-SHA1. section 3.4.3
296
+ #
297
+ # @return [String]
298
+ def rsa_sha1_signature
299
+ private_key = OpenSSL::PKey::RSA.new(@attributes['consumer_secret'])
300
+ Base64.encode64(private_key.sign(OpenSSL::Digest::SHA1.new, signature_base)).chomp.gsub(/\n/, '')
301
+ end
302
+
303
+ # signature, with method HMAC-SHA1. section 3.4.2
304
+ #
305
+ # @return [String]
306
+ def hmac_sha1_signature
307
+ # hmac secret is same as plaintext signature
308
+ secret = plaintext_signature
309
+ Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::SHA1.new, secret, signature_base)).chomp.gsub(/\n/, '')
310
+ end
311
+
312
+ # signature, with method plaintext. section 3.4.4
313
+ #
314
+ # @return [String]
315
+ def plaintext_signature
316
+ @attributes.values_at('consumer_secret', 'token_secret').map { |v| OAuthenticator.escape(v) }.join('&')
317
+ end
318
+
319
+ # body hash, with a signature method which uses SHA1. oauth request body hash section 3.2
320
+ #
321
+ # @return [String]
322
+ def sha1_body_hash
323
+ Base64.encode64(OpenSSL::Digest::SHA1.digest(read_body)).chomp.gsub(/\n/, '')
324
+ end
325
+
326
+ # map of oauth signature methods to their signature instance methods on this class
327
+ SIGNATURE_METHODS = {
328
+ 'RSA-SHA1'.freeze => instance_method(:rsa_sha1_signature),
329
+ 'HMAC-SHA1'.freeze => instance_method(:hmac_sha1_signature),
330
+ 'PLAINTEXT'.freeze => instance_method(:plaintext_signature),
331
+ }.freeze
332
+
333
+ # map of oauth signature methods to their body hash instance methods on this class. oauth request body
334
+ # hash section 3.1
335
+ BODY_HASH_METHODS = {
336
+ 'RSA-SHA1'.freeze => instance_method(:sha1_body_hash),
337
+ 'HMAC-SHA1'.freeze => instance_method(:sha1_body_hash),
338
+ }.freeze
339
+ end
340
+ end