cerner-oauth1a 2.3.0 → 2.5.3

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.
@@ -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)
@@ -2,12 +2,11 @@
2
2
 
3
3
  module Cerner
4
4
  module OAuth1a
5
-
6
5
  # Internal: A Railtie that initializer the cache implementation to use Rails.cache.
7
6
  # This will be picked up automatically if ::Rails and ::Rails.cache are defined.
8
7
  class CacheRailtie < ::Rails::Railtie
9
8
  initializer 'cerner-oauth1a.cache_initialization' do |_app|
10
- ::Rails.logger.info "#{CacheRailtie.name}: configuring cache to use Rails.cache"
9
+ ::Rails.logger.info("#{CacheRailtie.name}: configuring cache to use Rails.cache")
11
10
  Cerner::OAuth1a::Cache.instance = RailsCache.new(::Rails.cache)
12
11
  end
13
12
  end
@@ -20,6 +19,7 @@ module Cerner
20
19
  #
21
20
  # rails_cache - An instance of ActiveSupport::Cache::Store.
22
21
  def initialize(rails_cache)
22
+ super()
23
23
  @cache = rails_cache
24
24
  end
25
25
 
@@ -29,13 +29,7 @@ module Cerner
29
29
  # key - The key for the cache entries, which is qualified by namespace.
30
30
  # entry - The entry to be stored in the cache.
31
31
  def put(namespace, key, entry)
32
- @cache.write(
33
- key,
34
- entry,
35
- namespace: namespace,
36
- expires_in: entry.expires_in,
37
- race_condition_ttl: 5
38
- )
32
+ @cache.write(key, entry, namespace: namespace, expires_in: entry.expires_in, race_condition_ttl: 5)
39
33
  end
40
34
 
41
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