cerner-oauth1a 2.4.0 → 2.5.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,10 +5,12 @@ require 'cerner/oauth1a/access_token'
5
5
  require 'cerner/oauth1a/keys'
6
6
  require 'cerner/oauth1a/oauth_error'
7
7
  require 'cerner/oauth1a/cache'
8
+ require 'cerner/oauth1a/internal'
8
9
  require 'cerner/oauth1a/protocol'
10
+ require 'cerner/oauth1a/signature'
9
11
  require 'cerner/oauth1a/version'
10
12
  require 'json'
11
- require 'net/https'
13
+ require 'net/http'
12
14
  require 'securerandom'
13
15
  require 'uri'
14
16
 
@@ -70,9 +72,11 @@ module Cerner
70
72
  # realm that's extracted from :access_token_url. If nil,
71
73
  # this will be initalized with the DEFAULT_REALM_ALIASES.
72
74
  # (optional, default: nil)
75
+ # :signature_method - A String to set the signature method to use. MUST be
76
+ # PLAINTEXT or HMAC-SHA1. (optional, default: 'PLAINTEXT')
73
77
  #
74
78
  # Raises ArgumentError if access_token_url, consumer_key or consumer_key is nil; if
75
- # access_token_url is an invalid URI.
79
+ # access_token_url is an invalid URI; if signature_method is invalid.
76
80
  def initialize(
77
81
  access_token_url:,
78
82
  consumer_key:,
@@ -81,7 +85,8 @@ module Cerner
81
85
  read_timeout: 5,
82
86
  cache_keys: true,
83
87
  cache_access_tokens: true,
84
- realm_aliases: nil
88
+ realm_aliases: nil,
89
+ signature_method: 'PLAINTEXT'
85
90
  )
86
91
  raise ArgumentError, 'consumer_key is nil' unless consumer_key
87
92
  raise ArgumentError, 'consumer_secret is nil' unless consumer_secret
@@ -89,7 +94,7 @@ module Cerner
89
94
  @consumer_key = consumer_key
90
95
  @consumer_secret = consumer_secret
91
96
 
92
- @access_token_url = convert_to_http_uri(access_token_url)
97
+ @access_token_url = Internal.convert_to_http_uri(url: access_token_url, name: 'access_token_url')
93
98
  @realm = Protocol.realm_for(@access_token_url)
94
99
  @realm_aliases = realm_aliases
95
100
  @realm_aliases ||= DEFAULT_REALM_ALIASES[@realm]
@@ -99,6 +104,9 @@ module Cerner
99
104
 
100
105
  @keys_cache = cache_keys ? Cache.instance : nil
101
106
  @access_token_cache = cache_access_tokens ? Cache.instance : nil
107
+
108
+ @signature_method = signature_method || 'PLAINTEXT'
109
+ raise ArgumentError, 'signature_method is invalid' unless Signature::METHODS.include?(@signature_method)
102
110
  end
103
111
 
104
112
  # Public: Retrieves the service provider keys from the configured Access Token service endpoint
@@ -107,7 +115,7 @@ module Cerner
107
115
  #
108
116
  # keys_version - The version identifier of the keys to retrieve. This corresponds to the
109
117
  # KeysVersion parameter of the oauth_token.
110
- # keywords - The additional, optional keyword arguments for this method
118
+ # keywords - The keyword arguments:
111
119
  # :ignore_cache - A flag for indicating that the cache should be ignored and a
112
120
  # new Access Token should be retrieved.
113
121
  #
@@ -135,7 +143,7 @@ module Cerner
135
143
  # This method will use the #generate_accessor_secret, #generate_nonce and #generate_timestamp methods to
136
144
  # interact with the service, which can be overridden via a sub-class, if desired.
137
145
  #
138
- # keywords - The additional, optional keyword arguments for this method
146
+ # keywords - The keyword arguments:
139
147
  # :principal - An optional principal identifier, which is passed via the
140
148
  # xoauth_principal protocol parameter.
141
149
  # :ignore_cache - A flag for indicating that the cache should be ignored and a new
@@ -147,19 +155,20 @@ module Cerner
147
155
  # Raises StandardError sub-classes for any issues interacting with the service, such as networking issues.
148
156
  def retrieve(principal: nil, ignore_cache: false)
149
157
  cache_key = "#{@consumer_key}&#{principal}"
158
+
150
159
  if @access_token_cache && !ignore_cache
151
160
  cache_entry = @access_token_cache.get('cerner-oauth1a/access-tokens', cache_key)
152
161
  return cache_entry.value if cache_entry
153
162
  end
154
163
 
155
164
  # generate token request info
156
- nonce = generate_nonce
157
165
  timestamp = generate_timestamp
158
166
  accessor_secret = generate_accessor_secret
159
167
 
160
- request = retrieve_prepare_request(timestamp, nonce, accessor_secret, principal)
168
+ request = retrieve_prepare_request(timestamp: timestamp, accessor_secret: accessor_secret, principal: principal)
161
169
  response = http_client.request(request)
162
- access_token = retrieve_handle_response(response, timestamp, nonce, accessor_secret)
170
+ access_token =
171
+ retrieve_handle_response(response: response, timestamp: timestamp, accessor_secret: accessor_secret)
163
172
  @access_token_cache&.put('cerner-oauth1a/access-tokens', cache_key, Cache::AccessTokenEntry.new(access_token))
164
173
  access_token
165
174
  end
@@ -175,14 +184,14 @@ module Cerner
175
184
  #
176
185
  # Returns a String containing the nonce.
177
186
  def generate_nonce
178
- SecureRandom.hex
187
+ Internal.generate_nonce
179
188
  end
180
189
 
181
190
  # Public: Generate a Timestamp for invocations of the Access Token service.
182
191
  #
183
192
  # Returns an Integer representing the number of seconds since the epoch.
184
193
  def generate_timestamp
185
- Time.now.to_i
194
+ Internal.generate_timestamp
186
195
  end
187
196
 
188
197
  # Public: Determines if the passed realm is equivalent to the configured
@@ -224,46 +233,41 @@ module Cerner
224
233
  http
225
234
  end
226
235
 
227
- # Internal: Convert an Access Token URL into a URI with some verification checks
228
- #
229
- # access_token_url - A String URL or a URI instance
230
- # Returns a URI::HTTP or URI::HTTPS
231
- #
232
- # Raises ArgumentError if access_token_url is nil, invalid or not an HTTP/HTTPS URI
233
- def convert_to_http_uri(access_token_url)
234
- raise ArgumentError, 'access_token_url is nil' unless access_token_url
235
-
236
- if access_token_url.is_a?(URI)
237
- uri = access_token_url
238
- else
239
- begin
240
- uri = URI(access_token_url)
241
- rescue URI::InvalidURIError
242
- # raise argument error with cause
243
- raise ArgumentError, 'access_token_url is invalid'
244
- end
245
- end
246
-
247
- raise ArgumentError, 'access_token_url must be an HTTP or HTTPS URI' unless uri.is_a?(URI::HTTP)
248
-
249
- uri
250
- end
251
-
252
236
  # Internal: Prepare a request for #retrieve
253
- def retrieve_prepare_request(timestamp, nonce, accessor_secret, principal)
237
+ def retrieve_prepare_request(accessor_secret:, timestamp:, principal: nil)
254
238
  # construct a POST request
255
239
  request = Net::HTTP::Post.new(@access_token_url)
256
240
  # setup the data to construct the POST's message
257
- params = [
258
- [:oauth_consumer_key, @consumer_key],
259
- [:oauth_signature_method, 'PLAINTEXT'],
260
- [:oauth_version, '1.0'],
261
- [:oauth_timestamp, timestamp],
262
- [:oauth_nonce, nonce],
263
- [:oauth_signature, "#{@consumer_secret}&"],
264
- [:oauth_accessor_secret, accessor_secret]
265
- ]
266
- params << [:xoauth_principal, principal.to_s] if principal
241
+ params = {
242
+ oauth_consumer_key: Protocol.percent_encode(@consumer_key),
243
+ oauth_signature_method: @signature_method,
244
+ oauth_version: '1.0',
245
+ oauth_accessor_secret: accessor_secret
246
+ }
247
+ params[:xoauth_principal] = principal.to_s if principal
248
+
249
+ if @signature_method == 'PLAINTEXT'
250
+ sig = Signature.sign_via_plaintext(client_shared_secret: @consumer_secret, token_shared_secret: '')
251
+ elsif @signature_method == 'HMAC-SHA1'
252
+ params[:oauth_timestamp] = timestamp
253
+ params[:oauth_nonce] = generate_nonce
254
+ signature_base_string =
255
+ Signature.build_signature_base_string(
256
+ http_method: 'POST', fully_qualified_url: @access_token_url, params: params
257
+ )
258
+ sig =
259
+ Signature.sign_via_hmacsha1(
260
+ client_shared_secret: @consumer_secret,
261
+ token_shared_secret: '',
262
+ signature_base_string: signature_base_string
263
+ )
264
+ else
265
+ raise OAuthError.new('signature_method is invalid', nil, 'signature_method_rejected', nil, @realm)
266
+ end
267
+
268
+ params[:oauth_signature] = sig
269
+
270
+ params = params.map { |n, v| [n, v] }
267
271
  # set the POST's body as a URL form-encoded string
268
272
  request.set_form(params, MIME_WWW_FORM_URL_ENCODED, charset: 'UTF-8')
269
273
  request['Accept'] = MIME_WWW_FORM_URL_ENCODED
@@ -273,22 +277,22 @@ module Cerner
273
277
  end
274
278
 
275
279
  # Internal: Handle a response for #retrieve
276
- def retrieve_handle_response(response, timestamp, nonce, accessor_secret)
280
+ def retrieve_handle_response(response:, timestamp:, accessor_secret:)
277
281
  case response
278
282
  when Net::HTTPSuccess
279
283
  # Parse the HTTP response and convert it into a Symbol-keyed Hash
280
284
  tuples = Protocol.parse_url_query_string(response.body)
281
285
  # Use the parsed response to construct the AccessToken
282
- access_token = AccessToken.new(
283
- accessor_secret: accessor_secret,
284
- consumer_key: @consumer_key,
285
- expires_at: timestamp + tuples[:oauth_expires_in].to_i,
286
- nonce: nonce,
287
- timestamp: timestamp,
288
- token: tuples[:oauth_token],
289
- token_secret: tuples[:oauth_token_secret],
290
- realm: @realm
291
- )
286
+ access_token =
287
+ AccessToken.new(
288
+ accessor_secret: accessor_secret,
289
+ consumer_key: @consumer_key,
290
+ expires_at: timestamp + tuples[:oauth_expires_in].to_i,
291
+ token: tuples[:oauth_token],
292
+ token_secret: tuples[:oauth_token_secret],
293
+ signature_method: @signature_method,
294
+ realm: @realm
295
+ )
292
296
  access_token
293
297
  else
294
298
  # Extract any OAuth Problems reported in the response
@@ -300,10 +304,11 @@ module Cerner
300
304
 
301
305
  # Internal: Prepare a request for #retrieve_keys
302
306
  def retrieve_keys_prepare_request(keys_version)
303
- request = Net::HTTP::Get.new(URI("#{@access_token_url}/keys/#{keys_version}"))
307
+ keys_url = URI("#{@access_token_url}/keys/#{keys_version}")
308
+ request = Net::HTTP::Get.new(keys_url)
304
309
  request['Accept'] = 'application/json'
305
310
  request['User-Agent'] = user_agent_string
306
- request['Authorization'] = retrieve.authorization_header
311
+ request['Authorization'] = retrieve.authorization_header(fully_qualified_url: keys_url)
307
312
  request
308
313
  end
309
314
 
@@ -319,9 +324,7 @@ module Cerner
319
324
  raise OAuthError.new('RSA public key retrieved was invalid', nil, nil, nil, @realm) unless rsa_key
320
325
 
321
326
  Keys.new(
322
- version: keys_version,
323
- aes_secret_key: Base64.decode64(aes_key),
324
- rsa_public_key: Base64.decode64(rsa_key)
327
+ version: keys_version, aes_secret_key: Base64.decode64(aes_key), rsa_public_key: Base64.decode64(rsa_key)
325
328
  )
326
329
  else
327
330
  # Extract any OAuth Problems reported in the response
@@ -6,17 +6,17 @@ module Cerner
6
6
  class Cache
7
7
  @cache_instance_lock = Mutex.new
8
8
 
9
+ # Internal: Sets the singleton instance.
9
10
  def self.instance=(cache_impl)
10
11
  raise ArgumentError, 'cache_impl must not be nil' unless cache_impl
11
12
 
12
- @cache_instance_lock.synchronize do
13
- @cache_instance = cache_impl
14
- end
13
+ @cache_instance_lock.synchronize { @cache_instance = cache_impl }
15
14
  end
16
15
 
16
+ # Internal: Gets the singleton instance.
17
17
  def self.instance
18
18
  @cache_instance_lock.synchronize do
19
- return @cache_instance if @cache_instance
19
+ return @cache_instance if instance_variable_defined?(:@cache_instance) && @cache_instance
20
20
 
21
21
  @cache_instance = DefaultCache.new(max: 50)
22
22
  end
@@ -27,12 +27,14 @@ module Cerner
27
27
  attr_reader :value
28
28
  attr_reader :expires_in
29
29
 
30
+ # Internal: Constructs an instance.
30
31
  def initialize(keys, expires_in)
31
32
  @value = keys
32
33
  @expires_in = expires_in
33
34
  @expires_at = Time.now.utc.to_i + @expires_in
34
35
  end
35
36
 
37
+ # Internal: Check if the entry is expired.
36
38
  def expired?(now)
37
39
  @expires_at <= now
38
40
  end
@@ -42,25 +44,29 @@ module Cerner
42
44
  class AccessTokenEntry
43
45
  attr_reader :value
44
46
 
47
+ # Internal: Constructs an instance.
45
48
  def initialize(access_token)
46
49
  @value = access_token
47
50
  end
48
51
 
52
+ # Internal: Returns the number of seconds until the entry expires.
49
53
  def expires_in
50
54
  @value.expires_at.to_i - Time.now.utc.to_i
51
55
  end
52
56
 
57
+ # Internal: Check if the entry is expired.
53
58
  def expired?(now)
54
59
  @value.expired?(now: now)
55
60
  end
56
61
  end
57
62
 
58
- ONE_HOUR = 3600
63
+ ONE_HOUR = 3_600
59
64
  TWENTY_FOUR_HOURS = 24 * ONE_HOUR
60
65
 
61
66
  # Internal: The default implementation of the Cerner::OAuth1a::Cache interface.
62
67
  # This implementation just maintains a capped list of entries in memory.
63
68
  class DefaultCache < Cerner::OAuth1a::Cache
69
+ # Internal: Constructs an instance.
64
70
  def initialize(max:)
65
71
  super()
66
72
  @max = max
@@ -68,6 +74,7 @@ module Cerner
68
74
  @entries = {}
69
75
  end
70
76
 
77
+ # Internal: Puts an entry into the cache.
71
78
  def put(namespace, key, entry)
72
79
  @lock.synchronize do
73
80
  now = Time.now.utc.to_i
@@ -77,6 +84,7 @@ module Cerner
77
84
  end
78
85
  end
79
86
 
87
+ # Internal: Gets an entry from the cache.
80
88
  def get(namespace, key)
81
89
  @lock.synchronize do
82
90
  prune_expired(Time.now.utc.to_i)
@@ -19,6 +19,7 @@ module Cerner
19
19
  #
20
20
  # rails_cache - An instance of ActiveSupport::Cache::Store.
21
21
  def initialize(rails_cache)
22
+ super()
22
23
  @cache = rails_cache
23
24
  end
24
25
 
@@ -28,13 +29,7 @@ module Cerner
28
29
  # key - The key for the cache entries, which is qualified by namespace.
29
30
  # entry - The entry to be stored in the cache.
30
31
  def put(namespace, key, entry)
31
- @cache.write(
32
- key,
33
- entry,
34
- namespace: namespace,
35
- expires_in: entry.expires_in,
36
- race_condition_ttl: 5
37
- )
32
+ @cache.write(key, entry, namespace: namespace, expires_in: entry.expires_in, race_condition_ttl: 5)
38
33
  end
39
34
 
40
35
  # Internal: Retrieves the entry, if available, from the cache store.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'uri'
5
+
6
+ module Cerner
7
+ module OAuth1a
8
+ # Internal: Internal utility methods
9
+ module Internal
10
+ # Internal: Convert a time value into a Time instance.
11
+ #
12
+ # keywords - The keyword arguments:
13
+ # :time - Time or any object with a #to_i that returns an Integer.
14
+ # :name - The parameter name of the data for invoking methods.
15
+ #
16
+ # Returns a Time instance in the UTC time zone.
17
+ def self.convert_to_time(time:, name: 'time')
18
+ raise ArgumentError, "#{name} is nil" unless time
19
+
20
+ time.is_a?(Time) ? time.utc : Time.at(time.to_i).utc
21
+ end
22
+
23
+ # Internal: Convert an fully qualified URL String into a URI with some verification checks
24
+ #
25
+ # keywords - The keyword arguments:
26
+ # :url - A String or a URI instance to convert to a URI instance.
27
+ # :name - The parameter name of the URL for invoking methods.
28
+ #
29
+ # Returns a URI::HTTP or URI::HTTPS
30
+ #
31
+ # Raises ArgumentError if url is nil, invalid or not an HTTP/HTTPS URI
32
+ def self.convert_to_http_uri(url:, name: 'url')
33
+ raise ArgumentError, "#{name} is nil" unless url
34
+
35
+ if url.is_a?(URI)
36
+ uri = url
37
+ else
38
+ begin
39
+ uri = URI(url)
40
+ rescue URI::InvalidURIError
41
+ # raise argument error with cause
42
+ raise ArgumentError, "#{name} is invalid"
43
+ end
44
+ end
45
+
46
+ raise ArgumentError, "#{name} must be an HTTP or HTTPS URI" unless uri.is_a?(URI::HTTP)
47
+
48
+ uri
49
+ end
50
+
51
+ # Internal: Generate a Nonce for invocations of the Access Token service.
52
+ #
53
+ # Returns a String containing the nonce.
54
+ def self.generate_nonce
55
+ SecureRandom.hex
56
+ end
57
+
58
+ # Internal: Generate a Timestamp for invocations of the Access Token service.
59
+ #
60
+ # Returns an Integer representing the number of seconds since the epoch.
61
+ def self.generate_timestamp
62
+ Time.now.to_i
63
+ end
64
+
65
+ # Internal: Compares two Strings using a constant time algorithm to protect against timing
66
+ # attacks.
67
+ #
68
+ # left - The left String
69
+ # right - The right String
70
+ #
71
+ # Return true if left and right match, false otherwise.
72
+ def self.constant_time_compare(left, right)
73
+ max_size = [left.bytesize, right.bytesize].max
74
+ # convert left and right to array of bytes (Integer)
75
+ left = left.unpack('C*')
76
+ right = right.unpack('C*')
77
+
78
+ # if either array is not the max size, expand it with zeros
79
+ # having equal arrays keeps the algorithm execution time constant
80
+ left = left.fill(0, left.size, max_size - left.size) if left.size < max_size
81
+ right = right.fill(0, right.size, max_size - right.size) if right.size < max_size
82
+
83
+ result = 0
84
+ left.each_with_index do |left_value, i|
85
+ # XOR the two bytes, if equal, the operation is 0
86
+ # OR the XOR operation with the previous result
87
+ result |= left_value ^ right[i]
88
+ end
89
+
90
+ # if every comparison resuled in 0, then left and right are equal
91
+ result.zero?
92
+ end
93
+ end
94
+ end
95
+ end
@@ -6,6 +6,18 @@ module Cerner
6
6
  module OAuth1a
7
7
  # Public: OAuth 1.0a protocol utilities.
8
8
  module Protocol
9
+ # Public: Encodes the passed text using the percent encoding variant described in the OAuth
10
+ # 1.0a specification.
11
+ #
12
+ # Reference: https://tools.ietf.org/html/rfc5849#section-3.6
13
+ #
14
+ # text - A String containing the text to encode.
15
+ #
16
+ # Returns a String that has been encoded.
17
+ def self.percent_encode(text)
18
+ URI.encode_www_form_component(text).gsub('+', '%20')
19
+ end
20
+
9
21
  # Public: Parses a URL-encoded query string into a Hash with symbolized keys.
10
22
  #
11
23
  # query - String containing a URL-encoded query string to parse.
@@ -37,6 +49,10 @@ module Cerner
37
49
  # Cerner::OAuth1a::Protocol.parse_www_authenticate_header(header)
38
50
  # # => {:realm=>"https://test.host", :oauth_problem=>"token_expired"}
39
51
  #
52
+ # header = 'OAuth realm="https://test.host", oauth_problem=token_expired'
53
+ # Cerner::OAuth1a::Protocol.parse_www_authenticate_header(header)
54
+ # # => {:realm=>"https://test.host", :oauth_problem=>"token_expired"}
55
+ #
40
56
  # Returns a Hash with symbolized keys of all of the parameters.
41
57
  def self.parse_authorization_header(value)
42
58
  params = {}
@@ -45,10 +61,18 @@ module Cerner
45
61
  value = value.strip
46
62
  return params unless value.size > 6 && value[0..5].casecmp?('OAuth ')
47
63
 
48
- value.scan(/([^,\s=]*)=\"([^\"]*)\"/).each do |pair|
49
- k = URI.decode_www_form_component(pair[0])
50
- v = URI.decode_www_form_component(pair[1])
51
- params[k.to_sym] = v
64
+ # trim off 'OAuth ' prefix
65
+ value = value[6..-1]
66
+
67
+ # split value on comma separators
68
+ value.split(/,\s*/).each do |kv_part|
69
+ # split each part on '=' separator
70
+ key, value = kv_part.split('=')
71
+ key = URI.decode_www_form_component(key)
72
+ # trim off surrounding double quotes, if they exist
73
+ value = value[1..-2] if value.start_with?('"') && value.end_with?('"')
74
+ value = URI.decode_www_form_component(value)
75
+ params[key.to_sym] = value
52
76
  end
53
77
 
54
78
  params
@@ -73,17 +97,12 @@ module Cerner
73
97
  #
74
98
  # Returns the String containing the generated value or nil if params is nil or empty.
75
99
  def self.generate_authorization_header(params)
76
- return nil unless params && !params.empty?
100
+ return unless params && !params.empty?
77
101
 
78
102
  realm = "realm=\"#{params.delete(:realm)}\"" if params[:realm]
79
- realm += ', ' if realm && !params.empty?
80
-
81
- encoded_params =
82
- params.map do |k, v|
83
- k = URI.encode_www_form_component(k).gsub('+', '%20')
84
- v = URI.encode_www_form_component(v).gsub('+', '%20')
85
- "#{k}=\"#{v}\""
86
- end
103
+ realm += ',' if realm && !params.empty?
104
+
105
+ encoded_params = params.map { |k, v| "#{percent_encode(k)}=\"#{percent_encode(v)}\"" }
87
106
 
88
107
  "OAuth #{realm}#{encoded_params.join(',')}"
89
108
  end