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.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/haveapi.gemspec +1 -1
- data/lib/haveapi/action.rb +125 -36
- data/lib/haveapi/actions/paginable.rb +3 -1
- data/lib/haveapi/authentication/basic/provider.rb +2 -0
- data/lib/haveapi/authentication/chain.rb +11 -7
- data/lib/haveapi/authentication/oauth2/config.rb +25 -3
- data/lib/haveapi/authentication/oauth2/provider.rb +92 -11
- data/lib/haveapi/authentication/oauth2/revoke_endpoint.rb +44 -3
- data/lib/haveapi/authentication/token/provider.rb +53 -15
- data/lib/haveapi/authorization.rb +42 -18
- data/lib/haveapi/client_examples/php_client.rb +1 -1
- data/lib/haveapi/client_examples/ruby_client.rb +1 -1
- data/lib/haveapi/context.rb +10 -4
- data/lib/haveapi/example.rb +15 -16
- data/lib/haveapi/extensions/action_exceptions.rb +6 -6
- data/lib/haveapi/model_adapters/active_record.rb +140 -68
- data/lib/haveapi/model_adapters/hash.rb +1 -1
- data/lib/haveapi/parameters/resource.rb +35 -3
- data/lib/haveapi/parameters/typed.rb +26 -7
- data/lib/haveapi/params.rb +27 -8
- data/lib/haveapi/resource.rb +4 -1
- data/lib/haveapi/resources/action_state.rb +8 -1
- data/lib/haveapi/route.rb +2 -2
- data/lib/haveapi/server.rb +137 -45
- data/lib/haveapi/validator.rb +2 -2
- data/lib/haveapi/validator_chain.rb +1 -0
- data/lib/haveapi/validators/confirmation.rb +1 -0
- data/lib/haveapi/validators/format.rb +6 -2
- data/lib/haveapi/validators/length.rb +2 -0
- data/lib/haveapi/validators/numericality.rb +2 -0
- data/lib/haveapi/validators/presence.rb +1 -1
- data/lib/haveapi/version.rb +1 -1
- data/lib/haveapi/views/version_page/client_auth.erb +1 -1
- data/lib/haveapi/views/version_page/client_example.erb +3 -3
- data/lib/haveapi/views/version_page/client_init.erb +1 -1
- data/lib/haveapi/views/version_page.erb +2 -2
- data/lib/haveapi/views/version_sidebar.erb +4 -2
- data/spec/action/authorize_spec.rb +99 -0
- data/spec/action/runtime_spec.rb +426 -0
- data/spec/action_state_spec.rb +52 -0
- data/spec/authentication/basic_spec.rb +29 -0
- data/spec/authentication/oauth2_spec.rb +329 -0
- data/spec/authentication/token_spec.rb +195 -0
- data/spec/authentication/token_version_routes_spec.rb +164 -0
- data/spec/authorization_spec.rb +66 -0
- data/spec/documentation/auth_filtering_spec.rb +195 -1
- data/spec/documentation/current_user_html_escaping_spec.rb +47 -0
- data/spec/documentation/examples_spec.rb +97 -0
- data/spec/documentation/host_html_escaping_spec.rb +41 -0
- data/spec/documentation_spec.rb +13 -0
- data/spec/extensions/action_exceptions_spec.rb +30 -0
- data/spec/model_adapters/active_record_spec.rb +406 -1
- data/spec/parameters/typed_spec.rb +42 -0
- data/spec/params_spec.rb +41 -0
- data/spec/server/integration_spec.rb +90 -0
- data/spec/validator_chain_spec.rb +39 -0
- data/spec/validators/confirmation_spec.rb +14 -0
- data/spec/validators/format_spec.rb +7 -0
- data/spec/validators/length_spec.rb +6 -0
- data/spec/validators/numericality_spec.rb +7 -0
- data/spec/validators/presence_spec.rb +2 -0
- data/test_support/client_test_api.rb +28 -0
- metadata +8 -4
- data/shell.nix +0 -20
data/spec/action_state_spec.rb
CHANGED
|
@@ -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
|