right_api_client 1.5.22 → 1.5.23

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,7 +1,10 @@
1
1
  # CHANGELOG.md
2
2
 
3
3
  ## Next
4
- - <Your change here>
4
+
5
+ ## 1.5.23
6
+ - \#78 Prevent logging of credentials during login requests
7
+ - \#77 Add support for OAuth2 authentication via refresh token
5
8
 
6
9
  ## 1.5.22
7
10
  - \#76 Add ability to directly access attributes of a ResourceDetail
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.5.22
1
+ 1.5.23
@@ -12,6 +12,43 @@ require File.expand_path('../resources', __FILE__)
12
12
  require File.expand_path('../errors', __FILE__)
13
13
  require File.expand_path('../exceptions', __FILE__)
14
14
 
15
+ # This is used to extend a temporary local copy of the client during login.
16
+ # It overrides the post functionality, which in turn creates a new instance of RestClient::Request
17
+ # which is then extended to override log_request to keep creds from getting into our logs.
18
+ module PostOverride
19
+ def post(payload, additional_headers={}, &block)
20
+ headers = (options[:headers] || {}).merge(additional_headers)
21
+ requestor = ::RestClient::Request.new(options.merge(
22
+ :method => :post,
23
+ :url => url,
24
+ :payload => payload,
25
+ :headers => headers)
26
+ )
27
+ requestor.extend(LogOverride)
28
+ requestor.execute(&block)
29
+ end
30
+ end
31
+
32
+ # This is used to extend a new instance of RestClient::Request and override it's log_request.
33
+ # Override version keeps email/password from getting into the logs.
34
+ module LogOverride
35
+ def log_request
36
+ if RestClient.log
37
+ out = []
38
+ out << "RestClient.#{method} #{url.inspect}"
39
+ if !payload.nil?
40
+ if (payload.short_inspect.include? "email") || (payload.short_inspect.include? "password")
41
+ out << "<hidden credentials>"
42
+ else
43
+ out << payload.short_inspect
44
+ end
45
+ end
46
+ out << processed_headers.to_a.sort.map { |(k, v)| [k.inspect, v.inspect].join("=>") }.join(", ")
47
+ RestClient.log << out.join(', ') + "\n"
48
+ end
49
+ end
50
+ end
51
+
15
52
  # RightApiClient has the generic get/post/delete/put calls that are used by resources
16
53
  module RightApi
17
54
  class Client
@@ -21,22 +58,60 @@ module RightApi
21
58
  DEFAULT_TIMEOUT = -1
22
59
  DEFAULT_MAX_ATTEMPTS = 5
23
60
 
24
- ROOT_RESOURCE = '/api/session'
61
+ ROOT_RESOURCE = '/api/session'
62
+ OAUTH_ENDPOINT = '/api/oauth2'
25
63
  ROOT_INSTANCE_RESOURCE = '/api/session/instance'
26
64
  DEFAULT_API_URL = 'https://my.rightscale.com'
27
65
 
28
66
  # permitted parameters for initializing
29
67
  AUTH_PARAMS = %w[
30
- email password_base64 password account_id api_url api_version
31
- cookies instance_token access_token timeout open_timeout max_attempts
68
+ email password_base64 password
69
+ instance_token
70
+ refresh_token access_token
71
+ cookies
72
+ account_id api_url api_version
73
+ timeout open_timeout max_attempts
32
74
  enable_retry rest_client_class
33
75
  ]
34
76
 
35
- attr_reader :cookies, :instance_token, :access_token, :last_request, :timeout, :open_timeout, :max_attempts, :enable_retry
36
- attr_accessor :account_id, :api_url
77
+ # @return [String] OAuth 2.0 refresh token if provided
78
+ attr_reader :refresh_token
37
79
 
38
- def initialize(args)
80
+ # @return [String] OAuth 2.0 access token, if present
81
+ attr_reader :access_token
82
+
83
+ # @return [Time] expiry timestamp for OAuth 2.0 access token
84
+ attr_reader :access_token_expires_at
85
+
86
+ attr_accessor :account_id
87
+
88
+ # @return [String] Base API url, e.g. https://us-3.rightscale.com
89
+ attr_accessor :api_url
90
+
91
+ # @return [String] instance API token as included in user-data
92
+ attr_reader :instance_token
93
+
94
+ # @return [Hash] collection of API cookies
95
+ # @deprecated please use OAuth 2.0 refresh tokens instead of password-based authentication
96
+ attr_reader :cookies
97
+
98
+ # @return [Hash] debug information about the last request and response
99
+ attr_reader :last_request
100
+
101
+ # @return [Integer] number of seconds to wait for socket open
102
+ attr_reader :open_timeout
103
+
104
+ # @return [Integer] number of seconds to wait for API response
105
+ attr_reader :timeout
106
+
107
+ # @return [Integer] number of times to retry idempotent requests (iff enable_retry == true)
108
+ attr_reader :max_attempts
109
+
110
+ # @return [Boolean] whether to retry idempotent requests that fail
111
+ attr_reader :enable_retry
39
112
 
113
+ # Instantiate a new Client.
114
+ def initialize(args)
40
115
  raise 'This API client is only compatible with Ruby 1.8.7 and upwards.' if (RUBY_VERSION < '1.8.7')
41
116
 
42
117
  @api_url, @api_version = DEFAULT_API_URL, API_VERSION
@@ -55,8 +130,9 @@ module RightApi
55
130
  @rest_client = @rest_client_class.new(@api_url, :open_timeout => @open_timeout, :timeout => @timeout)
56
131
  @last_request = {}
57
132
 
58
- # There are four options for login:
59
- # - credentials
133
+ # There are five options for login:
134
+ # - user email/password (using plaintext or base64-obfuscated password)
135
+ # - user OAuth refresh token
60
136
  # - instance API token
61
137
  # - existing user-supplied cookies
62
138
  # - existing user-supplied OAuth access token
@@ -103,7 +179,7 @@ module RightApi
103
179
  end
104
180
 
105
181
  def to_s
106
- "#<RightApi::Client>"
182
+ "#<RightApi::Client #{api_url}>"
107
183
  end
108
184
 
109
185
  # Log HTTP calls to file (file can be STDOUT as well)
@@ -127,7 +203,6 @@ module RightApi
127
203
  # so this is a workaround.
128
204
  #
129
205
  def resources(type, path)
130
-
131
206
  Resources.new(self, path, type)
132
207
  end
133
208
 
@@ -162,7 +237,7 @@ module RightApi
162
237
  end
163
238
  rescue ApiError => e
164
239
  if re_login?(e)
165
- # Session cookie is expired or invalid
240
+ # Session is expired or invalid
166
241
  login()
167
242
  retry
168
243
  else
@@ -172,25 +247,36 @@ module RightApi
172
247
  end
173
248
 
174
249
  def login
175
- params, path = if @instance_token
176
- [ { 'instance_token' => @instance_token },
177
- ROOT_INSTANCE_RESOURCE ]
250
+ account_href = "/api/accounts/#{@account_id}"
251
+
252
+ params, path =
253
+ if @refresh_token
254
+ [ {'grant_type' => 'refresh_token',
255
+ 'refresh_token'=>@refresh_token},
256
+ OAUTH_ENDPOINT ]
257
+ elsif @instance_token
258
+ [ { 'instance_token' => @instance_token,
259
+ 'account_href' => account_href },
260
+ ROOT_INSTANCE_RESOURCE ]
178
261
  elsif @password_base64
179
- [ { 'email' => @email, 'password' => Base64.decode64(@password_base64) },
262
+ [ { 'email' => @email,
263
+ 'password' => Base64.decode64(@password_base64),
264
+ 'account_href' => account_href },
180
265
  ROOT_RESOURCE ]
181
266
  else
182
- [ { 'email' => @email, 'password' => @password },
267
+ [ { 'email' => @email,
268
+ 'password' => @password,
269
+ 'account_href' => account_href },
183
270
  ROOT_RESOURCE ]
184
271
  end
185
- params['account_href'] = "/api/accounts/#{@account_id}"
186
272
 
187
273
  response = nil
188
274
  attempts = 0
189
275
  begin
190
- response = @rest_client[path].post(params, 'X-Api-Version' => @api_version) do |response, request, result, &block|
191
- if response.code == 302
276
+ response = @rest_client[path].extend(PostOverride).post(params, 'X-Api-Version' => @api_version) do |response, request, result, &block|
277
+ if [301, 302, 307].include?(response.code)
192
278
  update_api_url(response)
193
- response.follow_redirection(request, result, &block)
279
+ response = @rest_client[path].extend(PostOverride).post(params, 'X-Api-Version' => @api_version)
194
280
  else
195
281
  response.return!(request, result)
196
282
  end
@@ -202,17 +288,24 @@ module RightApi
202
288
  retry
203
289
  end
204
290
 
205
- update_cookies(response)
291
+ if path == OAUTH_ENDPOINT
292
+ update_access_token(response)
293
+ else
294
+ update_cookies(response)
295
+ end
206
296
  end
207
297
 
208
298
  # Returns the request headers
209
299
  def headers
210
300
  h = {
211
301
  'X-Api-Version' => @api_version,
212
- 'X-Account' => @account_id,
213
302
  :accept => :json,
214
303
  }
215
304
 
305
+ if @account_id
306
+ h['X-Account'] = @account_id
307
+ end
308
+
216
309
  if @access_token
217
310
  h['Authorization'] = "Bearer #{@access_token}"
218
311
  elsif @cookies
@@ -230,6 +323,7 @@ module RightApi
230
323
  # Generic get
231
324
  # params are NOT read only
232
325
  def do_get(path, params={})
326
+ login if need_login?
233
327
 
234
328
  # Resource id is a special param as it needs to be added to the path
235
329
  path = add_id_and_params_to_path(path, params)
@@ -280,6 +374,8 @@ module RightApi
280
374
 
281
375
  # Generic post
282
376
  def do_post(path, params={})
377
+ login if need_login?
378
+
283
379
  params = fix_array_of_hashes(params)
284
380
 
285
381
  req, res, resource_type, body = nil
@@ -334,6 +430,8 @@ module RightApi
334
430
 
335
431
  # Generic delete
336
432
  def do_delete(path, params={})
433
+ login if need_login?
434
+
337
435
  # Resource id is a special param as it needs to be added to the path
338
436
  path = add_id_and_params_to_path(path, params)
339
437
 
@@ -367,6 +465,8 @@ module RightApi
367
465
 
368
466
  # Generic put
369
467
  def do_put(path, params={})
468
+ login if need_login?
469
+
370
470
  params = fix_array_of_hashes(params)
371
471
 
372
472
  req, res, resource_type, body = nil
@@ -396,14 +496,31 @@ module RightApi
396
496
  end
397
497
  end
398
498
 
499
+ # Determine whether the client needs a fresh round of authentication based on state of
500
+ # cookies/tokens and their expiration timestamps.
501
+ #
502
+ # @return [Boolean] true if re-login is suggested
503
+ def need_login?
504
+ (@refresh_token && @refresh_token_expires_at && @refresh_token_expires_at - Time.now < 900) ||
505
+ (@cookies.respond_to?(:empty?) && @cookies.empty?)
506
+ end
507
+
508
+ # Determine whether an exception can be fixed by logging in again.
509
+ #
510
+ # @return [Boolean] true if re-login is appropriate
399
511
  def re_login?(e)
400
- # cannot successfully re-login with only an access token; we want the
401
- # expiration error to be raised.
402
- return false if @access_token
403
- e.message.index('403') && e.message =~ %r(.*Session cookie is expired or invalid)
512
+ auth_error =
513
+ (e.message.index('403') && e.message =~ %r(.*Session cookie is expired or invalid)) ||
514
+ e.message.index('401')
515
+
516
+ renewable_creds =
517
+ (@instance_token || (@email && (@password || @password_base64)) || @refresh_token)
518
+
519
+ auth_error && renewable_creds
404
520
  end
405
521
 
406
- # returns the resource_type
522
+ # @param [String] content_type an HTTP Content-Type header
523
+ # @return [String] the resource_type associated with content_type
407
524
  def get_resource_type(content_type)
408
525
  content_type.scan(/\.rightscale\.(.*)\+json/)[0][0]
409
526
  end
@@ -411,17 +528,23 @@ module RightApi
411
528
  # Makes sure the @cookies have a timestamp.
412
529
  #
413
530
  def timestamp_cookies
414
-
415
531
  return unless @cookies
416
532
 
417
533
  class << @cookies; attr_accessor :timestamp; end
418
534
  @cookies.timestamp = Time.now
419
535
  end
420
536
 
537
+ # Sets the @access_token and @access_token_expires_at
538
+ #
539
+ def update_access_token(response)
540
+ h = JSON.load(response)
541
+ @access_token = String(h['access_token'])
542
+ @access_token_expires_at = Time.at(Time.now.to_i + Integer(h['expires_in']))
543
+ end
544
+
421
545
  # Sets the @cookies (and timestamp it).
422
546
  #
423
547
  def update_cookies(response)
424
-
425
548
  return unless response.cookies
426
549
 
427
550
  (@cookies ||= {}).merge!(response.cookies)
@@ -432,7 +555,6 @@ module RightApi
432
555
  # A helper class for error details
433
556
  #
434
557
  class ErrorDetails
435
-
436
558
  attr_reader :method, :path, :params, :request, :response
437
559
 
438
560
  def initialize(me, pt, ps, rq, rs)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: right_api_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.22
4
+ version: 1.5.23
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2014-09-19 00:00:00.000000000 Z
12
+ date: 2014-10-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: json
@@ -174,7 +174,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
174
174
  version: '0'
175
175
  segments:
176
176
  - 0
177
- hash: -3655721947136781612
177
+ hash: 681643300708536250
178
178
  required_rubygems_version: !ruby/object:Gem::Requirement
179
179
  none: false
180
180
  requirements: