oauthenticator 0.1.4 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.simplecov +1 -0
- data/README.md +118 -35
- data/Rakefile.rb +11 -0
- data/lib/oauthenticator.rb +5 -6
- data/lib/oauthenticator/config_methods.rb +62 -21
- data/lib/oauthenticator/faraday_signer.rb +66 -0
- data/lib/oauthenticator/parse_authorization.rb +81 -0
- data/lib/oauthenticator/{middleware.rb → rack_authenticator.rb} +37 -9
- data/lib/oauthenticator/signable_request.rb +340 -0
- data/lib/oauthenticator/signed_request.rb +123 -112
- data/lib/oauthenticator/version.rb +3 -1
- data/test/config_methods_test.rb +10 -7
- data/test/faraday_signer_test.rb +65 -0
- data/test/helper.rb +15 -3
- data/test/parse_authorization_test.rb +86 -0
- data/test/{oauthenticator_test.rb → rack_authenticator_test.rb} +264 -136
- data/test/signable_request_test.rb +653 -0
- data/test/signed_request_test.rb +12 -0
- data/test/test_config_methods.rb +67 -0
- metadata +26 -11
@@ -1,4 +1,5 @@
|
|
1
|
-
require '
|
1
|
+
require 'oauthenticator/signable_request'
|
2
|
+
require 'oauthenticator/parse_authorization'
|
2
3
|
|
3
4
|
module OAuthenticator
|
4
5
|
# this class represents an OAuth signed request. its primary user-facing method is {#errors}, which returns
|
@@ -26,26 +27,27 @@ module OAuthenticator
|
|
26
27
|
end
|
27
28
|
end
|
28
29
|
|
29
|
-
|
30
|
-
|
30
|
+
# attributes of a SignedRequest
|
31
|
+
ATTRIBUTE_KEYS = %w(request_method uri body media_type authorization).map(&:freeze).freeze
|
32
|
+
|
33
|
+
# oauth attributes parsed from the request authorization
|
34
|
+
OAUTH_ATTRIBUTE_KEYS = (SignableRequest::PROTOCOL_PARAM_KEYS + %w(signature body_hash)).freeze
|
31
35
|
|
32
36
|
# readers
|
33
37
|
ATTRIBUTE_KEYS.each { |attribute_key| define_method(attribute_key) { @attributes[attribute_key] } }
|
34
38
|
|
35
39
|
# readers for oauth header parameters
|
36
|
-
OAUTH_ATTRIBUTE_KEYS.each { |key| define_method(key) { oauth_header_params[key] } }
|
40
|
+
OAUTH_ATTRIBUTE_KEYS.each { |key| define_method(key) { oauth_header_params["oauth_#{key}"] } }
|
37
41
|
|
38
42
|
# question methods to indicate whether oauth header parameters were included with a non-blank value in
|
39
43
|
# the Authorization header
|
40
44
|
OAUTH_ATTRIBUTE_KEYS.each do |key|
|
41
45
|
define_method("#{key}?") do
|
42
|
-
value = oauth_header_params[key]
|
46
|
+
value = oauth_header_params["oauth_#{key}"]
|
43
47
|
value.is_a?(String) ? !value.empty? : !!value
|
44
48
|
end
|
45
49
|
end
|
46
50
|
|
47
|
-
VALID_SIGNATURE_METHODS = %w(HMAC-SHA1 RSA-SHA1 PLAINTEXT).map(&:freeze).freeze
|
48
|
-
|
49
51
|
class << self
|
50
52
|
# instantiates a `OAuthenticator::SignedRequest` (subclass thereof, more precisely) representing a
|
51
53
|
# request given as a Rack::Request.
|
@@ -57,7 +59,7 @@ module OAuthenticator
|
|
57
59
|
def from_rack_request(request)
|
58
60
|
new({
|
59
61
|
:request_method => request.request_method,
|
60
|
-
:
|
62
|
+
:uri => request.url,
|
61
63
|
:body => request.body,
|
62
64
|
:media_type => request.media_type,
|
63
65
|
:authorization => request.env['HTTP_AUTHORIZATION'],
|
@@ -87,105 +89,134 @@ module OAuthenticator
|
|
87
89
|
#
|
88
90
|
# @return [nil, Hash<String, Array<String>>] either nil or a hash of errors
|
89
91
|
def errors
|
90
|
-
@errors
|
92
|
+
return @errors if instance_variables.any? { |ivar| ivar.to_s == '@errors' }
|
93
|
+
@errors = catch(:errors) do
|
91
94
|
if authorization.nil?
|
92
|
-
{'Authorization' => ["Authorization header is missing"]}
|
95
|
+
throw(:errors, {'Authorization' => ["Authorization header is missing"]})
|
93
96
|
elsif authorization !~ /\S/
|
94
|
-
{'Authorization' => ["Authorization header is blank"]}
|
95
|
-
|
96
|
-
|
97
|
+
throw(:errors, {'Authorization' => ["Authorization header is blank"]})
|
98
|
+
end
|
99
|
+
|
100
|
+
begin
|
101
|
+
oauth_header_params
|
102
|
+
rescue OAuthenticator::Error => parse_exception
|
103
|
+
throw(:errors, parse_exception.errors)
|
104
|
+
end
|
105
|
+
|
106
|
+
errors = Hash.new { |h,k| h[k] = [] }
|
107
|
+
|
108
|
+
# timestamp
|
109
|
+
if !timestamp?
|
110
|
+
unless signature_method == 'PLAINTEXT'
|
111
|
+
errors['Authorization oauth_timestamp'] << "is missing"
|
112
|
+
end
|
113
|
+
elsif timestamp !~ /\A\s*\d+\s*\z/
|
114
|
+
errors['Authorization oauth_timestamp'] << "is not an integer - got: #{timestamp}"
|
97
115
|
else
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
116
|
+
timestamp_i = timestamp.to_i
|
117
|
+
if timestamp_i < Time.now.to_i - timestamp_valid_past
|
118
|
+
errors['Authorization oauth_timestamp'] << "is too old: #{timestamp}"
|
119
|
+
elsif timestamp_i > Time.now.to_i + timestamp_valid_future
|
120
|
+
errors['Authorization oauth_timestamp'] << "is too far in the future: #{timestamp}"
|
103
121
|
end
|
104
|
-
|
105
|
-
if parse_exception.class.name == 'SimpleOAuth::ParseError'
|
106
|
-
message = parse_exception.message
|
107
|
-
else
|
108
|
-
message = "Authorization header is not a properly-formed OAuth 1.0 header."
|
109
|
-
end
|
110
|
-
{'Authorization' => [message]}
|
111
|
-
else
|
112
|
-
errors = Hash.new { |h,k| h[k] = [] }
|
122
|
+
end
|
113
123
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
errors['Authorization oauth_timestamp'] << "is not an integer - got: #{timestamp}"
|
119
|
-
else
|
120
|
-
timestamp_i = timestamp.to_i
|
121
|
-
if timestamp_i < Time.now.to_i - timestamp_valid_past
|
122
|
-
errors['Authorization oauth_timestamp'] << "is too old: #{timestamp}"
|
123
|
-
elsif timestamp_i > Time.now.to_i + timestamp_valid_future
|
124
|
-
errors['Authorization oauth_timestamp'] << "is too far in the future: #{timestamp}"
|
125
|
-
end
|
126
|
-
end
|
124
|
+
# oauth version
|
125
|
+
if version? && version != '1.0'
|
126
|
+
errors['Authorization oauth_version'] << "must be 1.0; got: #{version}"
|
127
|
+
end
|
127
128
|
|
128
|
-
|
129
|
-
|
130
|
-
errors['Authorization oauth_version'] << "must be 1.0; got: #{version}"
|
131
|
-
end
|
129
|
+
# she's filled with secrets
|
130
|
+
secrets = {}
|
132
131
|
|
133
|
-
|
134
|
-
|
132
|
+
# consumer / client application
|
133
|
+
if !consumer_key?
|
134
|
+
errors['Authorization oauth_consumer_key'] << "is missing"
|
135
|
+
else
|
136
|
+
secrets[:consumer_secret] = consumer_secret
|
137
|
+
if !secrets[:consumer_secret]
|
138
|
+
errors['Authorization oauth_consumer_key'] << 'is invalid'
|
139
|
+
end
|
140
|
+
end
|
135
141
|
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
142
|
+
# token
|
143
|
+
if token?
|
144
|
+
secrets[:token_secret] = token_secret
|
145
|
+
if !secrets[:token_secret]
|
146
|
+
errors['Authorization oauth_token'] << 'is invalid'
|
147
|
+
elsif !token_belongs_to_consumer?
|
148
|
+
errors['Authorization oauth_token'] << 'does not belong to the specified consumer'
|
149
|
+
end
|
150
|
+
end
|
145
151
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
end
|
152
|
+
# nonce
|
153
|
+
if !nonce?
|
154
|
+
unless signature_method == 'PLAINTEXT'
|
155
|
+
errors['Authorization oauth_nonce'] << "is missing"
|
156
|
+
end
|
157
|
+
elsif nonce_used?
|
158
|
+
errors['Authorization oauth_nonce'] << "has already been used"
|
159
|
+
end
|
155
160
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
161
|
+
# signature method
|
162
|
+
if !signature_method?
|
163
|
+
errors['Authorization oauth_signature_method'] << "is missing"
|
164
|
+
elsif !allowed_signature_methods.any? { |sm| signature_method.downcase == sm.downcase }
|
165
|
+
errors['Authorization oauth_signature_method'] << "must be one of " +
|
166
|
+
"#{allowed_signature_methods.join(', ')}; got: #{signature_method}"
|
167
|
+
end
|
162
168
|
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
errors['Authorization oauth_signature_method'] << "must be one of " +
|
168
|
-
"#{allowed_signature_methods.join(', ')}; got: #{signature_method}"
|
169
|
-
end
|
169
|
+
# signature
|
170
|
+
if !signature?
|
171
|
+
errors['Authorization oauth_signature'] << "is missing"
|
172
|
+
end
|
170
173
|
|
171
|
-
|
172
|
-
if !signature?
|
173
|
-
errors['Authorization oauth_signature'] << "is missing"
|
174
|
-
end
|
174
|
+
signable_request = SignableRequest.new(@attributes.merge(secrets).merge('authorization' => oauth_header_params))
|
175
175
|
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
176
|
+
# body hash
|
177
|
+
|
178
|
+
# present?
|
179
|
+
if body_hash?
|
180
|
+
# allowed?
|
181
|
+
if !signable_request.form_encoded?
|
182
|
+
# applicable?
|
183
|
+
if SignableRequest::BODY_HASH_METHODS.key?(signature_method)
|
184
|
+
# correct?
|
185
|
+
if body_hash == signable_request.body_hash
|
186
|
+
# all good
|
182
187
|
else
|
183
|
-
|
184
|
-
nil
|
188
|
+
errors['Authorization oauth_body_hash'] << "is invalid"
|
185
189
|
end
|
190
|
+
else
|
191
|
+
# received a body hash with plaintext. weird situation - we will ignore it; signature will not
|
192
|
+
# be verified but it will be a part of the signature.
|
193
|
+
end
|
194
|
+
else
|
195
|
+
errors['Authorization oauth_body_hash'] << "must not be included with form-encoded requests"
|
196
|
+
end
|
197
|
+
else
|
198
|
+
# allowed?
|
199
|
+
if !signable_request.form_encoded?
|
200
|
+
# required?
|
201
|
+
if body_hash_required?
|
202
|
+
errors['Authorization oauth_body_hash'] << "is required (on non-form-encoded requests)"
|
203
|
+
else
|
204
|
+
# okay - not supported by client, but allowed
|
186
205
|
end
|
206
|
+
else
|
207
|
+
# all good
|
187
208
|
end
|
188
209
|
end
|
210
|
+
|
211
|
+
throw(:errors, errors) if errors.any?
|
212
|
+
|
213
|
+
# proceed to check signature
|
214
|
+
unless self.signature == signable_request.signature
|
215
|
+
throw(:errors, {'Authorization oauth_signature' => ['is invalid']})
|
216
|
+
end
|
217
|
+
|
218
|
+
use_nonce!
|
219
|
+
nil
|
189
220
|
end
|
190
221
|
end
|
191
222
|
|
@@ -196,36 +227,16 @@ module OAuthenticator
|
|
196
227
|
|
197
228
|
# hash of header params. keys should be a subset of OAUTH_ATTRIBUTE_KEYS.
|
198
229
|
def oauth_header_params
|
199
|
-
@oauth_header_params ||=
|
200
|
-
end
|
201
|
-
|
202
|
-
# reads the request body, be it String or IO
|
203
|
-
def read_body
|
204
|
-
if body.is_a?(String)
|
205
|
-
body
|
206
|
-
elsif body.respond_to?(:read) && body.respond_to?(:rewind)
|
207
|
-
body.rewind
|
208
|
-
body.read.tap do
|
209
|
-
body.rewind
|
210
|
-
end
|
211
|
-
else
|
212
|
-
raise NotImplementedError, "body = #{body.inspect}"
|
213
|
-
end
|
214
|
-
end
|
215
|
-
|
216
|
-
# SimpleOAuth::Header for this request
|
217
|
-
def simple_oauth_header
|
218
|
-
params = media_type == "application/x-www-form-urlencoded" ? CGI.parse(read_body).map{|k,vs| vs.map{|v| [k,v] } }.inject([], &:+) : nil
|
219
|
-
simple_oauth_header = SimpleOAuth::Header.new(request_method, url, params, authorization)
|
230
|
+
@oauth_header_params ||= OAuthenticator.parse_authorization(authorization)
|
220
231
|
end
|
221
232
|
|
222
233
|
# raise a nice error message for a method that needs to be implemented on a module of config methods
|
223
234
|
def config_method_not_implemented
|
224
235
|
caller_name = caller[0].match(%r(in `(.*?)'))[1]
|
225
|
-
using_middleware = caller.any? { |l| l =~ %r(oauthenticator/
|
236
|
+
using_middleware = caller.any? { |l| l =~ %r(oauthenticator/rack_authenticator.rb:.*`call') }
|
226
237
|
message = "method \##{caller_name} must be implemented on a module of oauth config methods, which is " + begin
|
227
238
|
if using_middleware
|
228
|
-
"passed to OAuthenticator::
|
239
|
+
"passed to OAuthenticator::RackAuthenticator using the option :config_methods."
|
229
240
|
else
|
230
241
|
"included in a subclass of OAuthenticator::SignedRequest, typically by passing it to OAuthenticator::SignedRequest.including_config(your_module)."
|
231
242
|
end
|
data/test/config_methods_test.rb
CHANGED
@@ -3,7 +3,7 @@ proc { |p| $:.unshift(p) unless $:.any? { |lp| File.expand_path(lp) == p } }.cal
|
|
3
3
|
require 'helper'
|
4
4
|
|
5
5
|
describe OAuthenticator::SignedRequest do
|
6
|
-
%w(timestamp_valid_period consumer_secret
|
6
|
+
%w(timestamp_valid_period consumer_secret token_secret nonce_used? use_nonce! token_belongs_to_consumer?).each do |method_without_default|
|
7
7
|
it "complains when #{method_without_default} is not implemented" do
|
8
8
|
exc = assert_raises(NotImplementedError) do
|
9
9
|
OAuthenticator::SignedRequest.new({}).public_send(method_without_default)
|
@@ -17,15 +17,15 @@ describe OAuthenticator::SignedRequest do
|
|
17
17
|
assert called
|
18
18
|
end
|
19
19
|
end
|
20
|
-
it "complains when a method without a default is not implemented, using
|
20
|
+
it "complains when a method without a default is not implemented, using RackAuthenticator" do
|
21
21
|
exc = assert_raises(NotImplementedError) do
|
22
|
-
OAuthenticator::
|
22
|
+
OAuthenticator::RackAuthenticator.new(proc {}, {:config_methods => Module.new}).call({'HTTP_AUTHORIZATION' => %q(OAuth oauth_timestamp="1")})
|
23
23
|
end
|
24
|
-
assert_match /passed to OAuthenticator::
|
24
|
+
assert_match /passed to OAuthenticator::RackAuthenticator using the option :config_methods./, exc.message
|
25
25
|
end
|
26
|
-
it "complains
|
26
|
+
it "complains RackAuthenticator is not given config methods" do
|
27
27
|
assert_raises(ArgumentError) do
|
28
|
-
OAuthenticator::
|
28
|
+
OAuthenticator::RackAuthenticator.new(proc {})
|
29
29
|
end
|
30
30
|
end
|
31
31
|
it 'uses timestamp_valid_period if that is implemented but timestamp_valid_past or timestamp_valid_future is not' do
|
@@ -36,6 +36,9 @@ describe OAuthenticator::SignedRequest do
|
|
36
36
|
assert_equal 2, called
|
37
37
|
end
|
38
38
|
it 'uses the default value for allowed signature methods' do
|
39
|
-
assert_equal
|
39
|
+
assert_equal %w(RSA-SHA1 HMAC-SHA1 PLAINTEXT), OAuthenticator::SignedRequest.new({}).allowed_signature_methods
|
40
|
+
end
|
41
|
+
it 'uses default value for body_hash_required?' do
|
42
|
+
assert_equal false, OAuthenticator::SignedRequest.new({}).body_hash_required?
|
40
43
|
end
|
41
44
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
proc { |p| $:.unshift(p) unless $:.any? { |lp| File.expand_path(lp) == p } }.call(File.expand_path('.', File.dirname(__FILE__)))
|
3
|
+
require 'helper'
|
4
|
+
|
5
|
+
# not going to test a ton here, since the Faraday middleware mostly just calls to SignableRequest which is
|
6
|
+
# rather well-tested
|
7
|
+
describe OAuthenticator::FaradaySigner do
|
8
|
+
def assert_response(expected_status, expected_body, faraday_response)
|
9
|
+
assert_equal expected_status.to_i, faraday_response.status.to_i, "Expected status to be #{expected_status.inspect}" +
|
10
|
+
"; got #{faraday_response.status.inspect}. body was: #{faraday_response.body}"
|
11
|
+
assert expected_body === faraday_response.body, "Expected match for #{expected_body}; got #{faraday_response.body}"
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'succeeds' do
|
15
|
+
signing_options = {
|
16
|
+
:signature_method => 'PLAINTEXT',
|
17
|
+
:consumer_key => consumer_key,
|
18
|
+
:consumer_secret => consumer_secret,
|
19
|
+
:token => token,
|
20
|
+
:token_secret => token_secret,
|
21
|
+
}
|
22
|
+
|
23
|
+
connection = Faraday.new(:url => 'http://example.com') do |faraday|
|
24
|
+
faraday.request :oauthenticator_signer, signing_options
|
25
|
+
faraday.adapter :rack, oapp
|
26
|
+
end
|
27
|
+
response = connection.get '/'
|
28
|
+
assert_response 200, '☺', response
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'succeeds with form-encoded with HMAC' do
|
32
|
+
signing_options = {
|
33
|
+
:signature_method => 'HMAC-SHA1',
|
34
|
+
:consumer_key => consumer_key,
|
35
|
+
:consumer_secret => consumer_secret,
|
36
|
+
:token => token,
|
37
|
+
:token_secret => token_secret,
|
38
|
+
}
|
39
|
+
|
40
|
+
connection = Faraday.new(:url => 'http://example.com') do |faraday|
|
41
|
+
faraday.request :url_encoded
|
42
|
+
faraday.request :oauthenticator_signer, signing_options
|
43
|
+
faraday.adapter :rack, oapp
|
44
|
+
end
|
45
|
+
response = connection.put('/', :foo => {:bar => :baz})
|
46
|
+
assert_response 200, '☺', response
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'is unauthorized' do
|
50
|
+
signing_options = {
|
51
|
+
:signature_method => 'PLAINTEXT',
|
52
|
+
:consumer_key => consumer_key,
|
53
|
+
:consumer_secret => 'nope',
|
54
|
+
:token => token,
|
55
|
+
:token_secret => 'definitelynot',
|
56
|
+
}
|
57
|
+
|
58
|
+
connection = Faraday.new(:url => 'http://example.com') do |faraday|
|
59
|
+
faraday.request :oauthenticator_signer, signing_options
|
60
|
+
faraday.adapter :rack, oapp
|
61
|
+
end
|
62
|
+
response = connection.get '/'
|
63
|
+
assert_response 401, /Authorization oauth_signature.*is invalid/m, response
|
64
|
+
end
|
65
|
+
end
|
data/test/helper.rb
CHANGED
@@ -2,11 +2,10 @@ proc { |p| $:.unshift(p) unless $:.any? { |lp| File.expand_path(lp) == p } }.cal
|
|
2
2
|
|
3
3
|
require 'simplecov'
|
4
4
|
|
5
|
-
require '
|
6
|
-
Debugger.start
|
5
|
+
require 'byebug'
|
7
6
|
|
8
7
|
# NO EXPECTATIONS
|
9
|
-
ENV["MT_NO_EXPECTATIONS"]
|
8
|
+
ENV["MT_NO_EXPECTATIONS"] = ''
|
10
9
|
|
11
10
|
require 'minitest/autorun'
|
12
11
|
require 'minitest/reporters'
|
@@ -16,3 +15,16 @@ require 'rack/test'
|
|
16
15
|
require 'timecop'
|
17
16
|
|
18
17
|
require 'oauthenticator'
|
18
|
+
|
19
|
+
require 'test_config_methods'
|
20
|
+
|
21
|
+
class OAuthenticatorConfigSpec < Minitest::Spec
|
22
|
+
after do
|
23
|
+
Timecop.return
|
24
|
+
end
|
25
|
+
|
26
|
+
include TestHelperMethods
|
27
|
+
end
|
28
|
+
|
29
|
+
# register this to be the base class for specs instead of Minitest::Spec
|
30
|
+
Minitest::Spec.register_spec_type(//, OAuthenticatorConfigSpec)
|