haveapi 0.27.3 → 0.28.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/haveapi.gemspec +1 -1
  4. data/lib/haveapi/action.rb +125 -36
  5. data/lib/haveapi/actions/paginable.rb +3 -1
  6. data/lib/haveapi/authentication/basic/provider.rb +2 -0
  7. data/lib/haveapi/authentication/chain.rb +11 -7
  8. data/lib/haveapi/authentication/oauth2/config.rb +25 -3
  9. data/lib/haveapi/authentication/oauth2/provider.rb +92 -11
  10. data/lib/haveapi/authentication/oauth2/revoke_endpoint.rb +44 -3
  11. data/lib/haveapi/authentication/token/provider.rb +53 -15
  12. data/lib/haveapi/authorization.rb +42 -18
  13. data/lib/haveapi/client_examples/php_client.rb +1 -1
  14. data/lib/haveapi/client_examples/ruby_client.rb +1 -1
  15. data/lib/haveapi/context.rb +10 -4
  16. data/lib/haveapi/example.rb +15 -16
  17. data/lib/haveapi/extensions/action_exceptions.rb +6 -6
  18. data/lib/haveapi/model_adapters/active_record.rb +140 -68
  19. data/lib/haveapi/model_adapters/hash.rb +1 -1
  20. data/lib/haveapi/parameters/resource.rb +35 -3
  21. data/lib/haveapi/parameters/typed.rb +26 -7
  22. data/lib/haveapi/params.rb +27 -8
  23. data/lib/haveapi/resource.rb +4 -1
  24. data/lib/haveapi/resources/action_state.rb +8 -1
  25. data/lib/haveapi/route.rb +2 -2
  26. data/lib/haveapi/server.rb +137 -45
  27. data/lib/haveapi/validator.rb +2 -2
  28. data/lib/haveapi/validator_chain.rb +1 -0
  29. data/lib/haveapi/validators/confirmation.rb +1 -0
  30. data/lib/haveapi/validators/format.rb +6 -2
  31. data/lib/haveapi/validators/length.rb +2 -0
  32. data/lib/haveapi/validators/numericality.rb +2 -0
  33. data/lib/haveapi/validators/presence.rb +1 -1
  34. data/lib/haveapi/version.rb +1 -1
  35. data/lib/haveapi/views/version_page/client_auth.erb +1 -1
  36. data/lib/haveapi/views/version_page/client_example.erb +3 -3
  37. data/lib/haveapi/views/version_page/client_init.erb +1 -1
  38. data/lib/haveapi/views/version_page.erb +2 -2
  39. data/lib/haveapi/views/version_sidebar.erb +4 -2
  40. data/spec/action/authorize_spec.rb +99 -0
  41. data/spec/action/runtime_spec.rb +426 -0
  42. data/spec/action_state_spec.rb +52 -0
  43. data/spec/authentication/basic_spec.rb +29 -0
  44. data/spec/authentication/oauth2_spec.rb +329 -0
  45. data/spec/authentication/token_spec.rb +195 -0
  46. data/spec/authentication/token_version_routes_spec.rb +164 -0
  47. data/spec/authorization_spec.rb +66 -0
  48. data/spec/documentation/auth_filtering_spec.rb +195 -1
  49. data/spec/documentation/current_user_html_escaping_spec.rb +47 -0
  50. data/spec/documentation/examples_spec.rb +97 -0
  51. data/spec/documentation/host_html_escaping_spec.rb +41 -0
  52. data/spec/documentation_spec.rb +13 -0
  53. data/spec/extensions/action_exceptions_spec.rb +30 -0
  54. data/spec/model_adapters/active_record_spec.rb +406 -1
  55. data/spec/parameters/typed_spec.rb +42 -0
  56. data/spec/params_spec.rb +41 -0
  57. data/spec/server/integration_spec.rb +90 -0
  58. data/spec/validator_chain_spec.rb +39 -0
  59. data/spec/validators/confirmation_spec.rb +14 -0
  60. data/spec/validators/format_spec.rb +7 -0
  61. data/spec/validators/length_spec.rb +6 -0
  62. data/spec/validators/numericality_spec.rb +7 -0
  63. data/spec/validators/presence_spec.rb +2 -0
  64. data/test_support/client_test_api.rb +28 -0
  65. metadata +8 -4
  66. data/shell.nix +0 -20
@@ -2,6 +2,17 @@ require 'time'
2
2
 
3
3
  module ActionStateSpec
4
4
  FIXED_TIME = Time.utc(2020, 1, 1, 0, 0, 0)
5
+ User = Struct.new(:id)
6
+
7
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
8
+ protected
9
+
10
+ def find_user(_request, username, password)
11
+ return unless username == 'user' && password == 'pass'
12
+
13
+ User.new(1)
14
+ end
15
+ end
5
16
 
6
17
  class State
7
18
  attr_reader :id, :label, :created_at, :updated_at, :status, :progress, :poll_calls
@@ -209,6 +220,16 @@ describe HaveAPI::Resources::ActionState do
209
220
  expect(state.poll_calls).to eq(0)
210
221
  end
211
222
 
223
+ it 'rejects excessive poll timeout values' do
224
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
225
+
226
+ get_action '/v1/action_states/1/poll', action_state: { timeout: 31 }
227
+
228
+ expect(last_response.status).to eq(400)
229
+ expect(api_response).not_to be_ok
230
+ expect(api_response.errors[:timeout].first).to include('range <0, 30>')
231
+ end
232
+
212
233
  it 'poll returns immediately when update_in check mismatches' do
213
234
  state = ActionStateSpec::State.new(
214
235
  id: 1,
@@ -298,4 +319,35 @@ describe HaveAPI::Resources::ActionState do
298
319
  expect(api_response.message).to eq('not supported')
299
320
  end
300
321
  end
322
+
323
+ context 'with action_state backend and authentication' do
324
+ empty_api
325
+ use_version 1
326
+ default_version 1
327
+ action_state ActionStateSpec::Backend
328
+ auth_chain ActionStateSpec::BasicProvider
329
+
330
+ before do
331
+ ActionStateSpec::Backend.reset!
332
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
333
+ header 'Accept', 'application/json'
334
+ end
335
+
336
+ it 'requires authentication for action state resources' do
337
+ get_action '/v1/action_states'
338
+
339
+ expect(last_response.status).to eq(401)
340
+ expect(api_response).not_to be_ok
341
+ expect(ActionStateSpec::Backend.list_calls).to be_empty
342
+ end
343
+
344
+ it 'passes authenticated users to the action state backend' do
345
+ login('user', 'pass')
346
+ get_action '/v1/action_states'
347
+
348
+ expect(last_response.status).to eq(200)
349
+ expect(api_response).to be_ok
350
+ expect(ActionStateSpec::Backend.list_calls.last[:user].id).to eq(1)
351
+ end
352
+ end
301
353
  end
@@ -105,4 +105,33 @@ describe HaveAPI::Authentication::Basic::Provider do
105
105
  expect(api_response).to be_failed
106
106
  expect(seen_users.last).to be_nil
107
107
  end
108
+
109
+ it 'treats malformed Authorization headers as failed authentication' do
110
+ invalid = (+"Basic \xFF").force_encoding(Encoding::UTF_8)
111
+ header 'Authorization', invalid
112
+
113
+ call_api(:post, '/v1/secures/ping', {})
114
+
115
+ expect(last_response.status).to eq(401)
116
+ expect(api_response).to be_failed
117
+ expect(seen_users.last).to be_nil
118
+ end
119
+
120
+ it 'returns an authentication error envelope from _login' do
121
+ get '/_login'
122
+
123
+ expect(last_response.status).to eq(401)
124
+ expect(last_response.headers['www-authenticate']).to include('Basic realm=')
125
+ expect(api_response).to be_failed
126
+ expect(api_response.message).to include('authenticate')
127
+ end
128
+
129
+ it 'returns an authentication error envelope from _logout' do
130
+ get '/_logout'
131
+
132
+ expect(last_response.status).to eq(401)
133
+ expect(last_response.headers['www-authenticate']).to include('Basic realm=')
134
+ expect(api_response).to be_failed
135
+ expect(api_response.message).to include('authenticate')
136
+ end
108
137
  end
@@ -18,6 +18,122 @@ module AuthSpecOAuth2
18
18
  Provider = HaveAPI::Authentication::OAuth2.with_config(Config)
19
19
  end
20
20
 
21
+ module AuthSpecOAuth2Security
22
+ User = Struct.new(:id, :login)
23
+ AuthResult = Struct.new(:authenticated, :complete, :cancel, :params)
24
+
25
+ class Client < HaveAPI::Authentication::OAuth2::Client
26
+ def initialize # rubocop:disable Lint/MissingSuper
27
+ @client_id = 'client'
28
+ @redirect_uri = 'https://client.example/callback'
29
+ end
30
+
31
+ def check_secret(client_secret)
32
+ raise TypeError, "client_secret must be a String, got #{client_secret.class}" unless client_secret.is_a?(String)
33
+
34
+ client_secret == 'secret'
35
+ end
36
+ end
37
+
38
+ class Authorization < HaveAPI::Authentication::OAuth2::Authorization
39
+ def initialize( # rubocop:disable Lint/MissingSuper
40
+ redirect_uri: 'https://client.example/callback',
41
+ code_challenge: nil,
42
+ code_challenge_method: nil
43
+ )
44
+ @redirect_uri = redirect_uri
45
+ @code_challenge = code_challenge
46
+ @code_challenge_method = code_challenge_method
47
+ end
48
+
49
+ def check_code_validity(redirect_uri)
50
+ raise TypeError, "redirect_uri must be a String, got #{redirect_uri.class}" unless redirect_uri.is_a?(String)
51
+
52
+ redirect_uri == @redirect_uri
53
+ end
54
+ end
55
+
56
+ class Config < HaveAPI::Authentication::OAuth2::Config
57
+ class << self
58
+ attr_accessor :authorization
59
+ attr_reader :client_lookups, :revocations
60
+
61
+ def reset!
62
+ @authorization = Authorization.new
63
+ @client_lookups = []
64
+ @revocations = []
65
+ end
66
+ end
67
+
68
+ def base_url
69
+ 'https://api.example'
70
+ end
71
+
72
+ def find_user_by_access_token(_request, access_token)
73
+ raise TypeError, "access_token must be a String, got #{access_token.class}" unless access_token.is_a?(String)
74
+
75
+ access_token == 'abc' ? User.new(1, 'user') : nil
76
+ end
77
+
78
+ def find_client_by_id(client_id)
79
+ raise TypeError, "client_id must be a String, got #{client_id.class}" unless client_id.is_a?(String)
80
+
81
+ self.class.client_lookups << client_id
82
+ client_id == 'client' ? Client.new : nil
83
+ end
84
+
85
+ def handle_get_authorize(oauth2_request:, **)
86
+ AuthResult.new(true, true, false, oauth2_params(oauth2_request))
87
+ end
88
+
89
+ def get_authorization_code(auth_res)
90
+ self.class.authorization = Authorization.new(
91
+ redirect_uri: auth_res.params[:redirect_uri],
92
+ code_challenge: auth_res.params[:code_challenge],
93
+ code_challenge_method: auth_res.params[:code_challenge_method]
94
+ )
95
+ 'code'
96
+ end
97
+
98
+ def find_authorization_by_code(_client, code)
99
+ raise TypeError, "code must be a String, got #{code.class}" unless code.is_a?(String)
100
+
101
+ code == 'code' ? self.class.authorization : nil
102
+ end
103
+
104
+ def find_authorization_by_refresh_token(_client, refresh_token)
105
+ raise TypeError, "refresh_token must be a String, got #{refresh_token.class}" unless refresh_token.is_a?(String)
106
+
107
+ refresh_token == 'refresh-token' ? Authorization.new : nil
108
+ end
109
+
110
+ def get_tokens(_authorization, _request)
111
+ ['access-token', Time.now + 3600, 'refresh-token']
112
+ end
113
+
114
+ def refresh_tokens(_authorization, _request)
115
+ ['access-token-2', Time.now + 3600, 'refresh-token-2']
116
+ end
117
+
118
+ def handle_post_revoke(_request, token, token_type_hint: nil, client: nil)
119
+ raise TypeError, "token must be a String, got #{token.class}" unless token.is_a?(String)
120
+ if !token_type_hint.nil? && !token_type_hint.is_a?(String)
121
+ raise TypeError, "token_type_hint must be a String, got #{token_type_hint.class}"
122
+ end
123
+
124
+ self.class.revocations << {
125
+ token:,
126
+ token_type_hint:,
127
+ client_id: client&.client_id
128
+ }
129
+
130
+ token_type_hint == 'unsupported' ? :unsupported : :revoked
131
+ end
132
+ end
133
+
134
+ Provider = HaveAPI::Authentication::OAuth2.with_config(Config)
135
+ end
136
+
21
137
  describe HaveAPI::Authentication::OAuth2 do
22
138
  describe 'smoke' do
23
139
  api do
@@ -102,6 +218,25 @@ describe HaveAPI::Authentication::OAuth2 do
102
218
  expect(api_response.message).to match(/too many|multiple/i)
103
219
  end
104
220
 
221
+ it 'ignores structured access_token query values before backend lookup' do
222
+ expect do
223
+ call_api(:post, '/v1/secures/ping?access_token[]=abc', {})
224
+ end.not_to raise_error
225
+
226
+ expect(last_response.status).to eq(401)
227
+ expect(api_response).to be_failed
228
+ end
229
+
230
+ it 'treats malformed bearer headers as failed authentication' do
231
+ invalid = (+"Bearer \xFF").force_encoding(Encoding::UTF_8)
232
+ header 'Authorization', invalid
233
+
234
+ call_api(:post, '/v1/secures/ping', {})
235
+
236
+ expect(last_response.status).to eq(401)
237
+ expect(api_response).to be_failed
238
+ end
239
+
105
240
  it 'exposes oauth2 provider in version description' do
106
241
  call_api(:options, '/v1/')
107
242
 
@@ -124,4 +259,198 @@ describe HaveAPI::Authentication::OAuth2 do
124
259
  expect(desc[:revoke_url]).to end_with(desc[:revoke_path])
125
260
  end
126
261
  end
262
+
263
+ describe 'endpoint hardening' do
264
+ api do
265
+ define_resource(:Secure) do
266
+ version 1
267
+
268
+ define_action(:Ping) do
269
+ route 'ping'
270
+ http_method :post
271
+ auth true
272
+
273
+ input(:hash) {}
274
+ output(:hash) { integer :user_id }
275
+ authorize { allow }
276
+
277
+ def exec
278
+ { user_id: current_user.id }
279
+ end
280
+ end
281
+ end
282
+ end
283
+
284
+ default_version 1
285
+ auth_chain AuthSpecOAuth2Security::Provider
286
+
287
+ before do
288
+ AuthSpecOAuth2Security::Config.reset!
289
+ end
290
+
291
+ def oauth_json
292
+ JSON.parse(last_response.body)
293
+ end
294
+
295
+ it 'requires a verifier when a stored PKCE challenge omits the method' do
296
+ AuthSpecOAuth2Security::Config.authorization =
297
+ AuthSpecOAuth2Security::Authorization.new(code_challenge: 'expected-verifier')
298
+
299
+ post '/_auth/oauth2/token', {
300
+ grant_type: 'authorization_code',
301
+ code: 'code',
302
+ redirect_uri: 'https://client.example/callback',
303
+ client_id: 'client',
304
+ client_secret: 'secret'
305
+ }
306
+
307
+ expect(last_response.status).to eq(400)
308
+ expect(oauth_json['error']).to eq('invalid_grant')
309
+
310
+ post '/_auth/oauth2/token', {
311
+ grant_type: 'authorization_code',
312
+ code: 'code',
313
+ redirect_uri: 'https://client.example/callback',
314
+ client_id: 'client',
315
+ client_secret: 'secret',
316
+ code_verifier: 'expected-verifier'
317
+ }
318
+
319
+ expect(last_response.status).to eq(200)
320
+ expect(oauth_json['access_token']).to eq('access-token')
321
+ end
322
+
323
+ it 'preserves omitted PKCE methods as plain in authorization params' do
324
+ get '/_auth/oauth2/authorize', {
325
+ response_type: 'code',
326
+ client_id: 'client',
327
+ redirect_uri: 'https://client.example/callback',
328
+ code_challenge: 'expected-verifier'
329
+ }
330
+
331
+ expect(last_response.status).to eq(302)
332
+ authorization = AuthSpecOAuth2Security::Config.authorization
333
+ expect(authorization.code_challenge).to eq('expected-verifier')
334
+ expect(authorization.code_challenge_method).to eq('plain')
335
+ end
336
+
337
+ it 'rejects structured authorization endpoint parameters' do
338
+ [
339
+ { response_type: 'code', 'client_id' => ['client'], redirect_uri: 'https://client.example/callback' },
340
+ { response_type: 'code', client_id: 'client', 'redirect_uri' => ['https://client.example/callback'] },
341
+ {
342
+ response_type: 'code',
343
+ client_id: 'client',
344
+ redirect_uri: 'https://client.example/callback',
345
+ code_challenge: 'expected-verifier',
346
+ 'code_challenge_method' => ['plain']
347
+ }
348
+ ].each do |request_params|
349
+ AuthSpecOAuth2Security::Config.client_lookups.clear
350
+ get '/_auth/oauth2/authorize', request_params
351
+
352
+ expect(last_response.status).to eq(400)
353
+ expect(oauth_json['error']).to eq('invalid_request')
354
+ expect(AuthSpecOAuth2Security::Config.client_lookups).to be_empty
355
+ end
356
+ end
357
+
358
+ it 'returns controlled errors for authorization protocol failures' do
359
+ get '/_auth/oauth2/authorize', {
360
+ response_type: 'code',
361
+ redirect_uri: 'https://client.example/callback'
362
+ }
363
+
364
+ expect(last_response.status).to eq(400)
365
+ expect(oauth_json['error']).to eq('invalid_request')
366
+
367
+ get '/_auth/oauth2/authorize', {
368
+ response_type: 'bogus',
369
+ client_id: 'client',
370
+ redirect_uri: 'https://client.example/callback'
371
+ }
372
+
373
+ expect(last_response.status).to eq(400)
374
+ expect(oauth_json['error']).to eq('unsupported_response_type')
375
+ end
376
+
377
+ it 'rejects structured token endpoint parameters before callbacks' do
378
+ [
379
+ { grant_type: 'authorization_code', 'client_id' => ['client'], client_secret: 'secret', code: 'code' },
380
+ { grant_type: 'authorization_code', client_id: 'client', 'client_secret' => ['secret'], code: 'code' },
381
+ { grant_type: 'authorization_code', client_id: 'client', client_secret: 'secret', 'code' => ['code'] },
382
+ {
383
+ grant_type: 'authorization_code',
384
+ client_id: 'client',
385
+ client_secret: 'secret',
386
+ code: 'code',
387
+ 'redirect_uri' => ['https://client.example/callback']
388
+ },
389
+ { grant_type: 'refresh_token', client_id: 'client', client_secret: 'secret', 'refresh_token' => ['refresh-token'] },
390
+ { grant_type: 'authorization_code', client_id: 'client', client_secret: 'secret', code: 'code', 'code_verifier' => ['verifier'] }
391
+ ].each do |request_params|
392
+ AuthSpecOAuth2Security::Config.client_lookups.clear
393
+ post '/_auth/oauth2/token', request_params
394
+
395
+ expect(last_response.status).to eq(400)
396
+ expect(oauth_json['error']).to eq('invalid_request')
397
+ expect(AuthSpecOAuth2Security::Config.client_lookups).to be_empty
398
+ end
399
+ end
400
+
401
+ it 'requires OAuth2 client authentication before revocation' do
402
+ post '/_auth/oauth2/revoke', {
403
+ token: 'victim-refresh-token',
404
+ token_type_hint: 'refresh_token'
405
+ }
406
+
407
+ expect(last_response.status).to eq(401)
408
+ expect(oauth_json['error']).to eq('invalid_client')
409
+ expect(AuthSpecOAuth2Security::Config.revocations).to be_empty
410
+ end
411
+
412
+ it 'rejects malformed revoke requests before callbacks' do
413
+ [
414
+ { token_type_hint: 'access_token', client_id: 'client', client_secret: 'secret' },
415
+ { 'token' => ['abc'], client_id: 'client', client_secret: 'secret' },
416
+ { token: 'abc', 'token_type_hint' => ['refresh_token'], client_id: 'client', client_secret: 'secret' }
417
+ ].each do |request_params|
418
+ post '/_auth/oauth2/revoke', request_params
419
+
420
+ expect(last_response.status).to eq(400)
421
+ expect(oauth_json['error']).to eq('invalid_request')
422
+ expect(AuthSpecOAuth2Security::Config.revocations).to be_empty
423
+ end
424
+ end
425
+
426
+ it 'revokes only after valid client authentication' do
427
+ post '/_auth/oauth2/revoke', {
428
+ token: 'victim-refresh-token',
429
+ token_type_hint: 'refresh_token',
430
+ client_id: 'client',
431
+ client_secret: 'secret'
432
+ }
433
+
434
+ expect(last_response.status).to eq(200)
435
+ expect(AuthSpecOAuth2Security::Config.revocations).to contain_exactly(
436
+ {
437
+ token: 'victim-refresh-token',
438
+ token_type_hint: 'refresh_token',
439
+ client_id: 'client'
440
+ }
441
+ )
442
+ end
443
+
444
+ it 'returns OAuth2 errors for unsupported revoke token hints' do
445
+ post '/_auth/oauth2/revoke', {
446
+ token: 'victim-refresh-token',
447
+ token_type_hint: 'unsupported',
448
+ client_id: 'client',
449
+ client_secret: 'secret'
450
+ }
451
+
452
+ expect(last_response.status).to eq(400)
453
+ expect(oauth_json['error']).to eq('unsupported_token_type')
454
+ end
455
+ end
127
456
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable RSpec/MultipleDescribes
4
+ # rubocop:disable RSpec/RepeatedExampleGroupDescription
5
+
3
6
  require 'spec_helper'
4
7
  require 'securerandom'
5
8
 
@@ -8,8 +11,13 @@ module AuthSpecToken
8
11
 
9
12
  class Config < HaveAPI::Authentication::Token::Config
10
13
  class << self
14
+ attr_accessor :raise_on_find, :raise_on_renew, :raise_on_revoke
15
+
11
16
  def reset!
12
17
  @tokens = {}
18
+ @raise_on_find = false
19
+ @raise_on_renew = false
20
+ @raise_on_revoke = false
13
21
  end
14
22
 
15
23
  def tokens
@@ -39,6 +47,8 @@ module AuthSpecToken
39
47
 
40
48
  renew do
41
49
  handle do |_req, res|
50
+ raise HaveAPI::AuthenticationError, 'renew rejected' if Config.raise_on_renew
51
+
42
52
  res.valid_to = Time.now + 3600
43
53
  res.ok
44
54
  end
@@ -46,12 +56,17 @@ module AuthSpecToken
46
56
 
47
57
  revoke do
48
58
  handle do |req, res|
59
+ raise HaveAPI::AuthenticationError, 'revoke rejected' if Config.raise_on_revoke
60
+
49
61
  Config.tokens.delete(req.token)
50
62
  res.ok
51
63
  end
52
64
  end
53
65
 
54
66
  def find_user_by_token(_request, token)
67
+ raise TypeError, "token must be a String, got #{token.class}" unless token.is_a?(String)
68
+ raise HaveAPI::AuthenticationError, 'backend rejected token' if self.class.raise_on_find
69
+
55
70
  self.class.tokens[token]
56
71
  end
57
72
  end
@@ -125,6 +140,23 @@ describe HaveAPI::Authentication::Token do
125
140
  expect(api_response[:token][:token]).to be_a(String)
126
141
  end
127
142
 
143
+ it 'rejects token request intervals outside policy bounds' do
144
+ [-1, 0, 86_401].each do |interval|
145
+ call_api(:post, '/_auth/token/tokens', {
146
+ token: {
147
+ user: 'user',
148
+ password: 'pass',
149
+ lifetime: 'fixed',
150
+ interval:
151
+ }
152
+ })
153
+
154
+ expect(last_response.status).to eq(400)
155
+ expect(api_response).to be_failed
156
+ expect(api_response.errors[:interval]).not_to be_empty
157
+ end
158
+ end
159
+
128
160
  it 'returns 401 for protected action without token' do
129
161
  call_api(:post, '/v1/secures/ping', {})
130
162
 
@@ -164,6 +196,26 @@ describe HaveAPI::Authentication::Token do
164
196
  expect(api_response.message).to match(/too many|multiple/i)
165
197
  end
166
198
 
199
+ it 'ignores structured token query values before backend lookup' do
200
+ expect do
201
+ call_api(:post, '/v1/secures/ping?_auth_token[]=abc', {})
202
+ end.not_to raise_error
203
+
204
+ expect(last_response.status).to eq(401)
205
+ expect(api_response).to be_failed
206
+ end
207
+
208
+ it 'treats AuthenticationError from token lookup as failed authentication' do
209
+ token = request_token!
210
+ AuthSpecToken::Config.raise_on_find = true
211
+
212
+ header AuthSpecToken::Config.http_header, token
213
+ call_api(:post, '/v1/secures/ping', {})
214
+
215
+ expect(last_response.status).to eq(401)
216
+ expect(api_response).to be_failed
217
+ end
218
+
167
219
  it 'returns 400 for revoke when multiple tokens are provided' do
168
220
  token = request_token!
169
221
  param = AuthSpecToken::Config.query_parameter.to_s
@@ -203,6 +255,27 @@ describe HaveAPI::Authentication::Token do
203
255
  expect(last_response.status).to eq(401)
204
256
  end
205
257
 
258
+ it 'returns controlled errors when renew and revoke handlers reject authentication' do
259
+ token = request_token!
260
+
261
+ AuthSpecToken::Config.raise_on_renew = true
262
+ header AuthSpecToken::Config.http_header, token
263
+ call_api(:post, '/_auth/token/tokens/renew', {})
264
+
265
+ expect(last_response.status).to eq(200)
266
+ expect(api_response).to be_failed
267
+ expect(api_response.message).to eq('renew rejected')
268
+
269
+ AuthSpecToken::Config.raise_on_renew = false
270
+ AuthSpecToken::Config.raise_on_revoke = true
271
+ header AuthSpecToken::Config.http_header, token
272
+ call_api(:post, '/_auth/token/tokens/revoke', {})
273
+
274
+ expect(last_response.status).to eq(200)
275
+ expect(api_response).to be_failed
276
+ expect(api_response.message).to eq('revoke rejected')
277
+ end
278
+
206
279
  it 'requires auth for OPTIONS on revoke path, but not on request path' do
207
280
  call_api(:options, '/_auth/token/tokens?method=POST')
208
281
  expect(last_response.status).to eq(200)
@@ -231,3 +304,125 @@ describe HaveAPI::Authentication::Token do
231
304
  expect(auth[:token][:resources]).to have_key(:token)
232
305
  end
233
306
  end
307
+
308
+ module AuthSpecTokenCrossProvider
309
+ User = Struct.new(:id, :login)
310
+
311
+ class << self
312
+ def reset!
313
+ tokens.replace(
314
+ 'victim-token' => User.new(1, 'victim'),
315
+ 'attacker-token' => User.new(2, 'attacker')
316
+ )
317
+ revoked.clear
318
+ end
319
+
320
+ def tokens
321
+ @tokens ||= {}
322
+ end
323
+
324
+ def revoked
325
+ @revoked ||= []
326
+ end
327
+ end
328
+
329
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
330
+ protected
331
+
332
+ def find_user(_request, username, password)
333
+ return User.new(2, 'attacker') if username == 'attacker' && password == 'pass'
334
+
335
+ nil
336
+ end
337
+ end
338
+
339
+ class TokenConfig < HaveAPI::Authentication::Token::Config
340
+ request do
341
+ handle do |_req, res|
342
+ res.error = 'not used'
343
+ res
344
+ end
345
+ end
346
+
347
+ renew do
348
+ handle do |_req, res|
349
+ res.ok
350
+ end
351
+ end
352
+
353
+ revoke do
354
+ handle do |req, res|
355
+ AuthSpecTokenCrossProvider.revoked << {
356
+ current_user: req.user.login,
357
+ token: req.token
358
+ }
359
+ AuthSpecTokenCrossProvider.tokens.delete(req.token)
360
+ res.ok
361
+ end
362
+ end
363
+
364
+ def find_user_by_token(_request, token)
365
+ AuthSpecTokenCrossProvider.tokens[token]
366
+ end
367
+ end
368
+
369
+ TokenProvider = HaveAPI::Authentication::Token.with_config(TokenConfig)
370
+ end
371
+
372
+ describe HaveAPI::Authentication::Token do
373
+ api do
374
+ define_resource(:Secure) do
375
+ version 1
376
+
377
+ define_action(:Whoami) do
378
+ route 'whoami'
379
+ http_method :post
380
+ auth true
381
+
382
+ input(:hash) {}
383
+ output(:hash) { string :login }
384
+ authorize { allow }
385
+
386
+ def exec
387
+ { login: current_user.login }
388
+ end
389
+ end
390
+ end
391
+ end
392
+
393
+ default_version 1
394
+ auth_chain [
395
+ AuthSpecTokenCrossProvider::BasicProvider,
396
+ AuthSpecTokenCrossProvider::TokenProvider
397
+ ]
398
+
399
+ before do
400
+ AuthSpecTokenCrossProvider.reset!
401
+ end
402
+
403
+ it 'uses the token provider user for renew and revoke actions' do
404
+ login('attacker', 'pass')
405
+ call_api(:post, '/_auth/token/tokens/revoke?_auth_token=victim-token', {})
406
+
407
+ expect(last_response.status).to eq(200)
408
+ expect(api_response).to be_ok
409
+ expect(AuthSpecTokenCrossProvider.revoked).to contain_exactly(
410
+ {
411
+ current_user: 'victim',
412
+ token: 'victim-token'
413
+ }
414
+ )
415
+ end
416
+
417
+ it 'rejects token actions authenticated only by another provider' do
418
+ login('attacker', 'pass')
419
+ call_api(:post, '/_auth/token/tokens/revoke', {})
420
+
421
+ expect(last_response.status).to eq(401)
422
+ expect(api_response).to be_failed
423
+ expect(AuthSpecTokenCrossProvider.revoked).to be_empty
424
+ end
425
+ end
426
+
427
+ # rubocop:enable RSpec/RepeatedExampleGroupDescription
428
+ # rubocop:enable RSpec/MultipleDescribes