songkick-oauth2-provider 0.10.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.
Files changed (47) hide show
  1. data/README.rdoc +394 -0
  2. data/example/README.rdoc +11 -0
  3. data/example/application.rb +159 -0
  4. data/example/config.ru +3 -0
  5. data/example/environment.rb +11 -0
  6. data/example/models/connection.rb +9 -0
  7. data/example/models/note.rb +4 -0
  8. data/example/models/user.rb +6 -0
  9. data/example/public/style.css +78 -0
  10. data/example/schema.rb +27 -0
  11. data/example/views/authorize.erb +28 -0
  12. data/example/views/create_user.erb +3 -0
  13. data/example/views/error.erb +6 -0
  14. data/example/views/home.erb +25 -0
  15. data/example/views/layout.erb +25 -0
  16. data/example/views/login.erb +20 -0
  17. data/example/views/new_client.erb +25 -0
  18. data/example/views/new_user.erb +22 -0
  19. data/example/views/show_client.erb +15 -0
  20. data/lib/songkick/oauth2/model.rb +20 -0
  21. data/lib/songkick/oauth2/model/authorization.rb +126 -0
  22. data/lib/songkick/oauth2/model/client.rb +61 -0
  23. data/lib/songkick/oauth2/model/client_owner.rb +15 -0
  24. data/lib/songkick/oauth2/model/hashing.rb +29 -0
  25. data/lib/songkick/oauth2/model/resource_owner.rb +54 -0
  26. data/lib/songkick/oauth2/provider.rb +122 -0
  27. data/lib/songkick/oauth2/provider/access_token.rb +68 -0
  28. data/lib/songkick/oauth2/provider/authorization.rb +190 -0
  29. data/lib/songkick/oauth2/provider/error.rb +22 -0
  30. data/lib/songkick/oauth2/provider/exchange.rb +227 -0
  31. data/lib/songkick/oauth2/router.rb +79 -0
  32. data/lib/songkick/oauth2/schema.rb +17 -0
  33. data/lib/songkick/oauth2/schema/20120828112156_songkick_oauth2_schema_original_schema.rb +36 -0
  34. data/spec/factories.rb +27 -0
  35. data/spec/request_helpers.rb +52 -0
  36. data/spec/songkick/oauth2/model/authorization_spec.rb +216 -0
  37. data/spec/songkick/oauth2/model/client_spec.rb +55 -0
  38. data/spec/songkick/oauth2/model/resource_owner_spec.rb +88 -0
  39. data/spec/songkick/oauth2/provider/access_token_spec.rb +125 -0
  40. data/spec/songkick/oauth2/provider/authorization_spec.rb +346 -0
  41. data/spec/songkick/oauth2/provider/exchange_spec.rb +353 -0
  42. data/spec/songkick/oauth2/provider_spec.rb +545 -0
  43. data/spec/spec_helper.rb +62 -0
  44. data/spec/test_app/helper.rb +33 -0
  45. data/spec/test_app/provider/application.rb +68 -0
  46. data/spec/test_app/provider/views/authorize.erb +19 -0
  47. metadata +273 -0
@@ -0,0 +1,545 @@
1
+ require 'spec_helper'
2
+
3
+ describe Songkick::OAuth2::Provider do
4
+ before(:all) { TestApp::Provider.start(8000) }
5
+ after(:all) { TestApp::Provider.stop }
6
+
7
+ let(:params) { { 'response_type' => 'code',
8
+ 'client_id' => @client.client_id,
9
+ 'redirect_uri' => @client.redirect_uri }
10
+ }
11
+
12
+ before do
13
+ @client = Factory(:client, :name => 'Test client')
14
+ @owner = TestApp::User['Bob']
15
+ end
16
+
17
+ include RequestHelpers
18
+
19
+ describe "access grant request" do
20
+ shared_examples_for "asks for user permission" do
21
+ it "creates an authorization" do
22
+ auth = mock_request(Songkick::OAuth2::Provider::Authorization, :client => @client, :params => {}, :scopes => [], :valid? => true)
23
+ Songkick::OAuth2::Provider::Authorization.should_receive(:new).with(@owner, params, nil).and_return(auth)
24
+ get(params)
25
+ end
26
+
27
+ it "displays an authorization page" do
28
+ response = get(params)
29
+ response.code.to_i.should == 200
30
+ response.body.should =~ /Do you want to allow Test client/
31
+ response['Content-Type'].should =~ /text\/html/
32
+ end
33
+ end
34
+
35
+ describe "with valid parameters" do
36
+ it_should_behave_like "asks for user permission"
37
+ end
38
+
39
+ describe "for token requests" do
40
+ before { params['response_type'] = 'token' }
41
+ it_should_behave_like "asks for user permission"
42
+ end
43
+
44
+ describe "for code_and_token requests" do
45
+ before { params['response_type'] = 'code_and_token' }
46
+ it_should_behave_like "asks for user permission"
47
+ end
48
+
49
+ describe "enforcing SSL" do
50
+ before { Songkick::OAuth2::Provider.enforce_ssl = true }
51
+
52
+ it "does not allow non-SSL requests" do
53
+ response = get(params)
54
+ validate_response(response, 400, 'WAT')
55
+ end
56
+ end
57
+
58
+ describe "when there is already a pending authorization from the user" do
59
+ before do
60
+ @authorization = create_authorization(
61
+ :owner => @owner,
62
+ :client => @client,
63
+ :code => 'pending_code',
64
+ :scope => 'offline_access')
65
+ end
66
+
67
+ it "immediately redirects with the code" do
68
+ response = get(params)
69
+ response.code.to_i.should == 302
70
+ response['location'].should == 'https://client.example.com/cb?code=pending_code'
71
+ end
72
+
73
+ describe "when the client is requesting scopes it already has access to" do
74
+ before { params['scope'] = 'offline_access' }
75
+
76
+ it "immediately redirects with the code" do
77
+ response = get(params)
78
+ response.code.to_i.should == 302
79
+ response['location'].should == 'https://client.example.com/cb?code=pending_code&scope=offline_access'
80
+ end
81
+ end
82
+
83
+ describe "when the client is requesting scopes it doesn't have yet" do
84
+ before { params['scope'] = 'wall_publish' }
85
+ it_should_behave_like "asks for user permission"
86
+ end
87
+
88
+ describe "and the authorization does not have a code" do
89
+ before { @authorization.update_attribute(:code, nil) }
90
+
91
+ it "generates a new code and redirects" do
92
+ Songkick::OAuth2::Model::Authorization.should_not_receive(:create)
93
+ Songkick::OAuth2::Model::Authorization.should_not_receive(:new)
94
+ Songkick::OAuth2.should_receive(:random_string).and_return('new_code')
95
+ response = get(params)
96
+ response.code.to_i.should == 302
97
+ response['location'].should == 'https://client.example.com/cb?code=new_code'
98
+ end
99
+ end
100
+
101
+ describe "and the authorization is expired" do
102
+ before { @authorization.update_attribute(:expires_at, 2.hours.ago) }
103
+ it_should_behave_like "asks for user permission"
104
+ end
105
+ end
106
+
107
+ describe "when there is already a completed authorization from the user" do
108
+ before do
109
+ @authorization = create_authorization(
110
+ :owner => @owner,
111
+ :client => @client,
112
+ :code => nil,
113
+ :access_token => Songkick::OAuth2.hashify('complete_token'))
114
+ end
115
+
116
+ it "immediately redirects with a new code" do
117
+ Songkick::OAuth2.should_receive(:random_string).and_return('new_code')
118
+ response = get(params)
119
+ response.code.to_i.should == 302
120
+ response['location'].should == 'https://client.example.com/cb?code=new_code'
121
+ end
122
+
123
+ describe "for token requests" do
124
+ before { params['response_type'] = 'token' }
125
+
126
+ it "immediately redirects with a new token" do
127
+ Songkick::OAuth2.should_receive(:random_string).and_return('new_access_token')
128
+ response = get(params)
129
+ response.code.to_i.should == 302
130
+ response['location'].should == 'https://client.example.com/cb#access_token=new_access_token'
131
+ end
132
+
133
+ describe "with an invalid client_id" do
134
+ before { params['client_id'] = 'unknown_id' }
135
+
136
+ it "does not generate any new tokens" do
137
+ Songkick::OAuth2.should_not_receive(:random_string)
138
+ get(params)
139
+ end
140
+ end
141
+ end
142
+
143
+ it "does not create a new Authorization" do
144
+ get(params)
145
+ Songkick::OAuth2::Model::Authorization.count.should == 1
146
+ end
147
+
148
+ it "keeps the code and access token on the Authorization" do
149
+ get(params)
150
+ authorization = Songkick::OAuth2::Model::Authorization.first
151
+ authorization.code.should_not be_nil
152
+ authorization.access_token_hash.should_not be_nil
153
+ end
154
+ end
155
+
156
+ describe "with no parameters" do
157
+ let(:params) { {} }
158
+
159
+ it "renders an error page" do
160
+ response = get(params)
161
+ validate_response(response, 400, 'WAT')
162
+ end
163
+ end
164
+
165
+ describe "with a redirect_uri and no client_id" do
166
+ let(:params) { {'redirect_uri' => 'http://evilsite.com/callback'} }
167
+
168
+ it "renders an error page" do
169
+ response = get(params)
170
+ validate_response(response, 400, 'WAT')
171
+ end
172
+ end
173
+
174
+ describe "with a client_id and a bad redirect_uri" do
175
+ let(:params) { {'redirect_uri' => 'http://evilsite.com/callback',
176
+ 'client_id' => @client.client_id} }
177
+
178
+ it "redirects to the client's registered redirect_uri" do
179
+ response = get(params)
180
+ response.code.to_i.should == 302
181
+ response['location'].should == 'https://client.example.com/cb?error=invalid_request&error_description=Missing+required+parameter+response_type'
182
+ end
183
+ end
184
+
185
+ describe "with an invalid request" do
186
+ before { params.delete('response_type') }
187
+
188
+ it "redirects to the client's redirect_uri on error" do
189
+ response = get(params)
190
+ response.code.to_i.should == 302
191
+ response['location'].should == 'https://client.example.com/cb?error=invalid_request&error_description=Missing+required+parameter+response_type'
192
+ end
193
+
194
+ describe "with a state parameter" do
195
+ before { params['state'] = "Facebook\nmesses this\nup" }
196
+
197
+ it "redirects to the client, including the state param" do
198
+ response = get(params)
199
+ response.code.to_i.should == 302
200
+ response['location'].should == "https://client.example.com/cb?error=invalid_request&error_description=Missing+required+parameter+response_type&state=Facebook%0Amesses+this%0Aup"
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ describe "authorization confirmation from the user" do
207
+ let(:mock_auth) do
208
+ mock = mock Songkick::OAuth2::Provider::Authorization,
209
+ :redirect_uri => 'http://example.com/',
210
+ :response_status => 302
211
+
212
+ Songkick::OAuth2::Provider::Authorization.stub(:new).and_return(mock)
213
+ mock
214
+ end
215
+
216
+ describe "without the user's permission" do
217
+ before { params['allow'] = '' }
218
+
219
+ it "does not grant access" do
220
+ mock_auth.should_receive(:deny_access!)
221
+ allow_or_deny(params)
222
+ end
223
+
224
+ it "redirects to the client with an error" do
225
+ response = allow_or_deny(params)
226
+ response.code.to_i.should == 302
227
+ response['location'].should == 'https://client.example.com/cb?error=access_denied&error_description=The+user+denied+you+access'
228
+ end
229
+ end
230
+
231
+ describe "with valid parameters and user permission" do
232
+ before { Songkick::OAuth2.stub(:random_string).and_return('foo') }
233
+ before { params['allow'] = '1' }
234
+
235
+ describe "for code requests" do
236
+ it "grants access" do
237
+ mock_auth.should_receive(:grant_access!)
238
+ allow_or_deny(params)
239
+ end
240
+
241
+ it "redirects to the client with an authorization code" do
242
+ response = allow_or_deny(params)
243
+ response.code.to_i.should == 302
244
+ response['location'].should == 'https://client.example.com/cb?code=foo'
245
+ end
246
+
247
+ it "passes the state parameter through" do
248
+ params['state'] = 'illinois'
249
+ response = allow_or_deny(params)
250
+ response.code.to_i.should == 302
251
+ response['location'].should == 'https://client.example.com/cb?code=foo&state=illinois'
252
+ end
253
+
254
+ it "passes the scope parameter through" do
255
+ params['scope'] = 'foo bar'
256
+ response = allow_or_deny(params)
257
+ response.code.to_i.should == 302
258
+ response['location'].should == 'https://client.example.com/cb?code=foo&scope=foo+bar'
259
+ end
260
+ end
261
+
262
+ describe "for token requests" do
263
+ before { params['response_type'] = 'token' }
264
+
265
+ it "grants access" do
266
+ mock_auth.should_receive(:grant_access!)
267
+ allow_or_deny(params)
268
+ end
269
+
270
+ it "redirects to the client with an access token" do
271
+ response = allow_or_deny(params)
272
+ response.code.to_i.should == 302
273
+ response['location'].should == 'https://client.example.com/cb#access_token=foo&expires_in=10800'
274
+ end
275
+
276
+ it "passes the state parameter through" do
277
+ params['state'] = 'illinois'
278
+ response = allow_or_deny(params)
279
+ response.code.to_i.should == 302
280
+ response['location'].should == 'https://client.example.com/cb#access_token=foo&expires_in=10800&state=illinois'
281
+ end
282
+
283
+ it "passes the scope parameter through" do
284
+ params['scope'] = 'foo bar'
285
+ response = allow_or_deny(params)
286
+ response.code.to_i.should == 302
287
+ response['location'].should == 'https://client.example.com/cb#access_token=foo&expires_in=10800&scope=foo+bar'
288
+ end
289
+ end
290
+
291
+ describe "for code_and_token requests" do
292
+ before { params['response_type'] = 'code_and_token' }
293
+
294
+ it "grants access" do
295
+ mock_auth.should_receive(:grant_access!)
296
+ allow_or_deny(params)
297
+ end
298
+
299
+ it "redirects to the client with an access token" do
300
+ response = allow_or_deny(params)
301
+ response.code.to_i.should == 302
302
+ response['location'].should == 'https://client.example.com/cb?code=foo#access_token=foo&expires_in=10800'
303
+ end
304
+
305
+ it "passes the state parameter through" do
306
+ params['state'] = 'illinois'
307
+ response = allow_or_deny(params)
308
+ response.code.to_i.should == 302
309
+ response['location'].should == 'https://client.example.com/cb?code=foo&state=illinois#access_token=foo&expires_in=10800'
310
+ end
311
+
312
+ it "passes the scope parameter through" do
313
+ params['scope'] = 'foo bar'
314
+ response = allow_or_deny(params)
315
+ response.code.to_i.should == 302
316
+ response['location'].should == 'https://client.example.com/cb?code=foo#access_token=foo&expires_in=10800&scope=foo+bar'
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ describe "access token request" do
323
+ before do
324
+ @client = Factory(:client)
325
+ @authorization = Factory(:authorization, :client => @client, :owner => @owner, :expires_at => 3.hours.from_now)
326
+ end
327
+
328
+ let(:auth_params) { { 'client_id' => @client.client_id,
329
+ 'client_secret' => @client.client_secret }
330
+ }
331
+
332
+ describe "using authorization_code request" do
333
+ let(:query_params) { { 'client_id' => @client.client_id,
334
+ 'grant_type' => 'authorization_code',
335
+ 'code' => @authorization.code,
336
+ 'redirect_uri' => @client.redirect_uri }
337
+ }
338
+
339
+ let(:params) { auth_params.merge(query_params) }
340
+
341
+ describe "with valid parameters" do
342
+ it "does not respond to GET" do
343
+ Songkick::OAuth2::Provider::Authorization.should_not_receive(:new)
344
+ response = get(params)
345
+ validate_json_response(response, 400,
346
+ 'error' => 'invalid_request',
347
+ 'error_description' => 'Bad request: must be a POST request'
348
+ )
349
+ end
350
+
351
+ describe "enforcing SSL" do
352
+ before { Songkick::OAuth2::Provider.enforce_ssl = true }
353
+
354
+ it "does not allow non-SSL requests" do
355
+ response = get(params)
356
+ validate_json_response(response, 400,
357
+ 'error' => 'invalid_request',
358
+ 'error_description' => 'Bad request: must make requests using HTTPS'
359
+ )
360
+ end
361
+ end
362
+
363
+ it "creates a Token when using Basic Auth" do
364
+ token = mock_request(Songkick::OAuth2::Provider::Exchange, :response_body => 'Hello')
365
+ Songkick::OAuth2::Provider::Exchange.should_receive(:new).with(@owner, params, nil).and_return(token)
366
+ post_basic_auth(auth_params, query_params)
367
+ end
368
+
369
+ it "creates a Token when passing params in the POST body" do
370
+ token = mock_request(Songkick::OAuth2::Provider::Exchange, :response_body => 'Hello')
371
+ Songkick::OAuth2::Provider::Exchange.should_receive(:new).with(@owner, params, nil).and_return(token)
372
+ post(params)
373
+ end
374
+
375
+ it "returns a successful response" do
376
+ Songkick::OAuth2.stub(:random_string).and_return('random_access_token')
377
+ response = post_basic_auth(auth_params, query_params)
378
+ validate_json_response(response, 200, 'access_token' => 'random_access_token', 'expires_in' => 10800)
379
+ end
380
+
381
+ describe "with a scope parameter" do
382
+ before do
383
+ @authorization.update_attribute(:scope, 'foo bar')
384
+ end
385
+
386
+ it "passes the scope back in the success response" do
387
+ Songkick::OAuth2.stub(:random_string).and_return('random_access_token')
388
+ response = post_basic_auth(auth_params, query_params)
389
+ validate_json_response(response, 200,
390
+ 'access_token' => 'random_access_token',
391
+ 'scope' => 'foo bar',
392
+ 'expires_in' => 10800
393
+ )
394
+ end
395
+ end
396
+ end
397
+
398
+ describe "with invalid parameters" do
399
+ before { query_params.delete('code') }
400
+
401
+ it "returns an error response" do
402
+ response = post_basic_auth(auth_params, query_params)
403
+ validate_json_response(response, 400,
404
+ 'error' => 'invalid_request',
405
+ 'error_description' => 'Missing required parameter code'
406
+ )
407
+ end
408
+ end
409
+
410
+ describe "with mismatched client_id in POST params and Basic Auth params" do
411
+ before { query_params['client_id'] = 'foo' }
412
+
413
+ it "returns an error response" do
414
+ response = post_basic_auth(auth_params, query_params)
415
+ validate_json_response(response, 400,
416
+ 'error' => 'invalid_request',
417
+ 'error_description' => 'Bad request: client_id from Basic Auth and request body do not match'
418
+ )
419
+ end
420
+ end
421
+
422
+ describe "when there is an Authorization with code and token" do
423
+ before do
424
+ @authorization.update_attributes(:code => 'pending_code', :access_token => 'working_token')
425
+ Songkick::OAuth2.stub(:random_string).and_return('random_access_token')
426
+ end
427
+
428
+ it "returns a new access token" do
429
+ response = post(params)
430
+ validate_json_response(response, 200,
431
+ 'access_token' => 'random_access_token',
432
+ 'expires_in' => 10800
433
+ )
434
+ end
435
+
436
+ it "exchanges the code for the new token on the existing Authorization" do
437
+ post(params)
438
+ @authorization.reload
439
+ @authorization.code.should be_nil
440
+ @authorization.access_token_hash.should == Songkick::OAuth2.hashify('random_access_token')
441
+ end
442
+ end
443
+ end
444
+ end
445
+
446
+ describe "protected resource request" do
447
+ before do
448
+ @authorization = Factory(:authorization,
449
+ :owner => @owner,
450
+ :access_token => 'magic-key',
451
+ :scope => 'profile')
452
+ end
453
+
454
+ shared_examples_for "protected resource" do
455
+ it "creates an AccessToken response" do
456
+ mock_token = mock(Songkick::OAuth2::Provider::AccessToken)
457
+ mock_token.should_receive(:response_headers).and_return({})
458
+ mock_token.should_receive(:response_status).and_return(200)
459
+ mock_token.should_receive(:valid?).and_return(true)
460
+ Songkick::OAuth2::Provider::AccessToken.should_receive(:new).with(TestApp::User['Bob'], ['profile'], 'magic-key', nil).and_return(mock_token)
461
+ request('/user_profile', 'oauth_token' => 'magic-key')
462
+ end
463
+
464
+ it "allows access when the key is passed" do
465
+ response = request('/user_profile', 'oauth_token' => 'magic-key')
466
+ JSON.parse(response.body)['data'].should == 'Top secret'
467
+ response.code.to_i.should == 200
468
+ end
469
+
470
+ it "blocks access when the wrong key is passed" do
471
+ response = request('/user_profile', 'oauth_token' => 'is-the-password-books')
472
+ JSON.parse(response.body)['data'].should == 'No soup for you'
473
+ response.code.to_i.should == 401
474
+ response['WWW-Authenticate'].should == "OAuth realm='Demo App', error='invalid_token'"
475
+ end
476
+
477
+ it "blocks access when the no key is passed" do
478
+ response = request('/user_profile')
479
+ JSON.parse(response.body)['data'].should == 'No soup for you'
480
+ response.code.to_i.should == 401
481
+ response['WWW-Authenticate'].should == "OAuth realm='Demo App'"
482
+ end
483
+
484
+ describe "enforcing SSL" do
485
+ before { Songkick::OAuth2::Provider.enforce_ssl = true }
486
+
487
+ let(:authorization) do
488
+ Songkick::OAuth2::Model::Authorization.find_by_access_token_hash(Songkick::OAuth2.hashify('magic-key'))
489
+ end
490
+
491
+ it "blocks access when not using HTTPS" do
492
+ response = request('/user_profile', 'oauth_token' => 'magic-key')
493
+ JSON.parse(response.body)['data'].should == 'No soup for you'
494
+ response.code.to_i.should == 401
495
+ response['WWW-Authenticate'].should == "OAuth realm='Demo App', error='invalid_request'"
496
+ end
497
+
498
+ it "destroys the access token since it's been leaked" do
499
+ authorization.access_token_hash.should == Songkick::OAuth2.hashify('magic-key')
500
+ request('/user_profile', 'oauth_token' => 'magic-key')
501
+ authorization.reload
502
+ authorization.access_token_hash.should be_nil
503
+ end
504
+
505
+ it "keeps the access token if the wrong key is passed" do
506
+ authorization.access_token_hash.should == Songkick::OAuth2.hashify('magic-key')
507
+ request('/user_profile', 'oauth_token' => 'is-the-password-books')
508
+ authorization.reload
509
+ authorization.access_token_hash.should == Songkick::OAuth2.hashify('magic-key')
510
+ end
511
+ end
512
+ end
513
+
514
+ describe "for header-based requests" do
515
+ def request(path, params = {})
516
+ access_token = params.delete('oauth_token')
517
+ http = Net::HTTP.new('localhost', 8000)
518
+ qs = params.map { |k,v| "#{ CGI.escape k.to_s }=#{ CGI.escape v.to_s }" }.join('&')
519
+ header = {'Authorization' => "OAuth #{access_token}"}
520
+ http.request_get(path + '?' + qs, header)
521
+ end
522
+
523
+ it_should_behave_like "protected resource"
524
+ end
525
+
526
+ describe "for GET requests" do
527
+ def request(path, params = {})
528
+ qs = params.map { |k,v| "#{ CGI.escape k.to_s }=#{ CGI.escape v.to_s }" }.join('&')
529
+ uri = URI.parse('http://localhost:8000' + path + '?' + qs)
530
+ Net::HTTP.get_response(uri)
531
+ end
532
+
533
+ it_should_behave_like "protected resource"
534
+ end
535
+
536
+ describe "for POST requests" do
537
+ def request(path, params = {})
538
+ Net::HTTP.post_form(URI.parse('http://localhost:8000' + path), params)
539
+ end
540
+
541
+ it_should_behave_like "protected resource"
542
+ end
543
+ end
544
+ end
545
+