cerner-oauth1a 1.0.1 → 2.0.0.rc1

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.
@@ -1,15 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
1
4
  require 'cerner/oauth1a/access_token'
5
+ require 'cerner/oauth1a/keys'
2
6
  require 'cerner/oauth1a/oauth_error'
7
+ require 'cerner/oauth1a/cache'
8
+ require 'cerner/oauth1a/protocol'
3
9
  require 'cerner/oauth1a/version'
10
+ require 'json'
4
11
  require 'net/https'
5
12
  require 'securerandom'
6
13
  require 'uri'
7
14
 
8
15
  module Cerner
9
16
  module OAuth1a
10
-
11
- # Public: A User Agent for interacting with the Access Token service to acquire
12
- # Access Tokens.
17
+ # Public: A user agent for interacting with the Cerner OAuth 1.0a Access Token service to acquire
18
+ # consumer Access Tokens or service provider Keys.
13
19
  class AccessTokenAgent
14
20
  MIME_WWW_FORM_URL_ENCODED = 'application/x-www-form-urlencoded'
15
21
 
@@ -22,18 +28,41 @@ module Cerner
22
28
 
23
29
  # Public: Constructs an instance of the agent.
24
30
  #
31
+ # Caching - By default, AccessToken and Keys instances are maintained in a small, constrained
32
+ # memory cache used by #retrieve and #retrieve_keys, respectively.
33
+ #
34
+ # The AccessToken cache keeps a maximum of 5 entries and prunes them when they expire. As the
35
+ # cache is based on the #consumer_key and the 'principal' parameter, the cache has limited
36
+ # effect. It's strongly suggested that AccessToken's be cached independently, as well.
37
+ #
38
+ # The Keys cache keeps a maximum of 10 entries and prunes them 24 hours after retrieval.
39
+ #
25
40
  # arguments - The keyword arguments of the method:
26
- # :access_token_url - The String or URI of the Access Token service endpoint.
27
- # :consumer_key - The String of the Consumer Key of the account.
28
- # :consumer_secret - The String of the Consumer Secret of the account.
29
- # :open_timeout - An object responding to to_i. Used to set the timeout, in seconds,
30
- # for opening HTTP connections to the Access Token service (optional, default: 5).
31
- # :read_timeout - An object responding to to_i. Used to set the timeout, in seconds,
32
- # for reading data from HTTP connections to the Access Token service (optional, default: 5).
33
- #
34
- # Raises ArgumentError if access_token_url, consumer_key or consumer_key is nil; if access_token_url is
35
- # an invalid URI.
36
- def initialize(access_token_url:, consumer_key:, consumer_secret:, open_timeout: 5, read_timeout: 5)
41
+ # :access_token_url - The String or URI of the Access Token service endpoint.
42
+ # :consumer_key - The String of the Consumer Key of the account.
43
+ # :consumer_secret - The String of the Consumer Secret of the account.
44
+ # :open_timeout - An object responding to to_i. Used to set the timeout, in
45
+ # seconds, for opening HTTP connections to the Access Token
46
+ # service (optional, default: 5).
47
+ # :read_timeout - An object responding to to_i. Used to set the timeout, in
48
+ # seconds, for reading data from HTTP connections to the
49
+ # Access Token service (optional, default: 5).
50
+ # :cache_keys - A Boolean for configuring Keys caching within
51
+ # #retrieve_keys. (optional, default: true)
52
+ # :cache_access_tokens - A Boolean for configuring AccessToken caching within
53
+ # #retrieve. (optional, default: true)
54
+ #
55
+ # Raises ArgumentError if access_token_url, consumer_key or consumer_key is nil; if
56
+ # access_token_url is an invalid URI.
57
+ def initialize(
58
+ access_token_url:,
59
+ consumer_key:,
60
+ consumer_secret:,
61
+ open_timeout: 5,
62
+ read_timeout: 5,
63
+ cache_keys: true,
64
+ cache_access_tokens: true
65
+ )
37
66
  raise ArgumentError, 'consumer_key is nil' unless consumer_key
38
67
  raise ArgumentError, 'consumer_secret is nil' unless consumer_secret
39
68
 
@@ -44,73 +73,65 @@ module Cerner
44
73
 
45
74
  @open_timeout = (open_timeout ? open_timeout.to_i : 5)
46
75
  @read_timeout = (read_timeout ? read_timeout.to_i : 5)
76
+
77
+ @keys_cache = cache_keys ? Cache.new(max: 10) : nil
78
+ @access_token_cache = cache_access_tokens ? Cache.new(max: 5) : nil
47
79
  end
48
80
 
49
- # Public: Retrives an AccessToken from the configured Access Token service endpoint (#access_token_url).
50
- # This method will the #generate_accessor_secret, #generate_nonce and #generate_timestamp methods to
51
- # interact with the service, which can be overridden via a sub-class, if desired.
81
+ # Public: Retrieves the service provider keys from the configured Access Token service endpoint
82
+ # (@access_token_url). This method will invoke #retrieve to acquire an AccessToken to request
83
+ # the keys.
52
84
  #
53
- # Returns a AccessToken upon success.
85
+ # keys_version - The version identifier of the keys to retrieve. This corresponds to the
86
+ # KeysVersion parameter of the oauth_token.
54
87
  #
55
- # Raises OAuthError unless the service returns a HTTP Status Code of 200.
56
- # Raises StandardError sub-classes for any issues interacting with the service.
57
- def retrieve
58
- # construct a POST request
59
- request = Net::HTTP::Post.new @access_token_url
88
+ # Return a Keys instance upon success.
89
+ #
90
+ # Raises ArgumentError if keys_version is nil.
91
+ # Raises OAuthError for any functional errors returned within an HTTP 200 response.
92
+ # Raises StandardError sub-classes for any issues interacting with the service, such as networking issues.
93
+ def retrieve_keys(keys_version)
94
+ raise ArgumentError, 'keys_version is nil' unless keys_version
60
95
 
61
- # setup the data to construct the POST's message
62
- accessor_secret = generate_accessor_secret
63
- nonce = generate_nonce
64
- timestamp = generate_timestamp
65
- params = [
66
- [:oauth_consumer_key, @consumer_key],
67
- [:oauth_signature_method, 'PLAINTEXT'],
68
- [:oauth_version, '1.0'],
69
- [:oauth_timestamp, timestamp],
70
- [:oauth_nonce, nonce],
71
- [:oauth_signature, "#{@consumer_secret}&"],
72
- [:oauth_accessor_secret, accessor_secret]
73
- ]
74
- # set the POST's body as a URL form-encoded string
75
- request.set_form(params, MIME_WWW_FORM_URL_ENCODED, charset: 'UTF-8')
96
+ if @keys_cache
97
+ cache_entry = @keys_cache.get(keys_version)
98
+ return cache_entry.value if cache_entry
99
+ end
76
100
 
77
- request['Accept'] = MIME_WWW_FORM_URL_ENCODED
78
- # Set a custom User-Agent to help identify these invocation
79
- request['User-Agent'] = "cerner-oauth1a #{VERSION} (Ruby #{RUBY_VERSION})"
101
+ request = retrieve_keys_prepare_request(keys_version)
102
+ response = http_client.request(request)
103
+ keys = retrieve_keys_handle_response(keys_version, response)
104
+ @keys_cache&.put(keys_version, Cache::KeysEntry.new(keys, Cache::TWENTY_FOUR_HOURS))
105
+ keys
106
+ end
80
107
 
81
- http = Net::HTTP.new(@access_token_url.host, @access_token_url.port)
82
- if @access_token_url.scheme == 'https'
83
- # if the scheme is HTTPS, then enable SSL
84
- http.use_ssl = true
85
- # make sure to verify peers
86
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER
87
- # tweak the ciphers to eliminate unsafe options
88
- http.ciphers = 'DEFAULT:!aNULL:!eNULL:!LOW:!SSLv2:!RC4'
108
+ # Public: Retrieves an AccessToken from the configured Access Token service endpoint (#access_token_url).
109
+ # This method will use the #generate_accessor_secret, #generate_nonce and #generate_timestamp methods to
110
+ # interact with the service, which can be overridden via a sub-class, if desired.
111
+ #
112
+ # principal - An optional principal identifier, which is passed via the xoauth_principal protocol parameter.
113
+ #
114
+ # Returns a AccessToken upon success.
115
+ #
116
+ # Raises OAuthError for any functional errors returned within an HTTP 200 response.
117
+ # Raises StandardError sub-classes for any issues interacting with the service, such as networking issues.
118
+ def retrieve(principal = nil)
119
+ cache_key = "#{@consumer_key}&#{principal}"
120
+ if @access_token_cache
121
+ cache_entry = @access_token_cache.get(cache_key)
122
+ return cache_entry.value if cache_entry
89
123
  end
90
- http.open_timeout = @open_timeout
91
- http.read_timeout = @read_timeout
92
124
 
93
- response = http.request request
125
+ # generate token request info
126
+ nonce = generate_nonce
127
+ timestamp = generate_timestamp
128
+ accessor_secret = generate_accessor_secret
94
129
 
95
- case response
96
- when Net::HTTPSuccess
97
- # Part the HTTP response and convert it into a Symbol-keyed Hash
98
- tuples = Hash[URI.decode_www_form(response.body).map { |pair| [pair[0].to_sym, pair[1]] }]
99
- # Use the parsed response to construct the AccessToken
100
- access_token = AccessToken.new(accessor_secret: accessor_secret,
101
- consumer_key: @consumer_key,
102
- expires_at: timestamp + tuples[:oauth_expires_in].to_i,
103
- nonce: nonce,
104
- timestamp: timestamp,
105
- token: tuples[:oauth_token],
106
- token_secret: tuples[:oauth_token_secret])
107
- access_token
108
- else
109
- # Extract any OAuth Problems reported in the response
110
- oauth_data = parse_www_authenticate(response['WWW-Authenticate'])
111
- # Raise an error for a failure to acquire a token
112
- raise OAuthError.new('unable to acquire token', response.code, oauth_data['oauth_problem'])
113
- end
130
+ request = retrieve_prepare_request(timestamp, nonce, accessor_secret, principal)
131
+ response = http_client.request(request)
132
+ access_token = retrieve_handle_response(response, timestamp, nonce, accessor_secret)
133
+ @access_token_cache&.put(cache_key, Cache::AccessTokenEntry.new(access_token))
134
+ access_token
114
135
  end
115
136
 
116
137
  # Public: Generate an Accessor Secret for invocations of the Access Token service.
@@ -136,18 +157,28 @@ module Cerner
136
157
 
137
158
  private
138
159
 
139
- # Internal: Parse a WWW-Authenticate HTTP header for any OAuth
140
- # information, which is indicated by a value starting with 'OAuth '.
141
- #
142
- # value - The String containing the header value.
143
- #
144
- # Returns a Hash containing any name-value pairs found in the value.
145
- def parse_www_authenticate(value)
146
- return {} unless value
147
- value = value.strip
148
- return {} unless value.start_with?('OAuth ')
160
+ # Internal: Generate a User-Agent HTTP Header string
161
+ def user_agent_string
162
+ "cerner-oauth1a #{VERSION} (Ruby #{RUBY_VERSION})"
163
+ end
164
+
165
+ # Internal: Provide the HTTP client instance for invoking requests
166
+ def http_client
167
+ http = Net::HTTP.new(@access_token_url.host, @access_token_url.port)
168
+
169
+ if @access_token_url.scheme == 'https'
170
+ # if the scheme is HTTPS, then enable SSL
171
+ http.use_ssl = true
172
+ # make sure to verify peers
173
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
174
+ # tweak the ciphers to eliminate unsafe options
175
+ http.ciphers = 'DEFAULT:!aNULL:!eNULL:!LOW:!SSLv2:!RC4'
176
+ end
177
+
178
+ http.open_timeout = @open_timeout
179
+ http.read_timeout = @read_timeout
149
180
 
150
- Hash[value.scan(/([^\s=]*)=\"([^\"]*)\"/)]
181
+ http
151
182
  end
152
183
 
153
184
  # Internal: Convert an Access Token URL into a URI with some verification checks
@@ -163,17 +194,93 @@ module Cerner
163
194
  else
164
195
  begin
165
196
  uri = URI(access_token_url)
166
- rescue URI::InvalidURIError => e
197
+ rescue URI::InvalidURIError
167
198
  # raise argument error with cause
168
199
  raise ArgumentError, 'access_token_url is invalid'
169
200
  end
170
201
  end
171
- unless uri.is_a? URI::HTTP
172
- raise ArgumentError, 'access_token_url must be an HTTP or HTTPS URI'
173
- end
202
+ raise ArgumentError, 'access_token_url must be an HTTP or HTTPS URI' unless uri.is_a?(URI::HTTP)
174
203
  uri
175
204
  end
176
- end
177
205
 
206
+ # Internal: Prepare a request for #retrieve
207
+ def retrieve_prepare_request(timestamp, nonce, accessor_secret, principal)
208
+ # construct a POST request
209
+ request = Net::HTTP::Post.new(@access_token_url)
210
+ # setup the data to construct the POST's message
211
+ params = [
212
+ [:oauth_consumer_key, @consumer_key],
213
+ [:oauth_signature_method, 'PLAINTEXT'],
214
+ [:oauth_version, '1.0'],
215
+ [:oauth_timestamp, timestamp],
216
+ [:oauth_nonce, nonce],
217
+ [:oauth_signature, "#{@consumer_secret}&"],
218
+ [:oauth_accessor_secret, accessor_secret]
219
+ ]
220
+ params << [:xoauth_principal, principal.to_s] if principal
221
+ # set the POST's body as a URL form-encoded string
222
+ request.set_form(params, MIME_WWW_FORM_URL_ENCODED, charset: 'UTF-8')
223
+ request['Accept'] = MIME_WWW_FORM_URL_ENCODED
224
+ # Set a custom User-Agent to help identify these invocation
225
+ request['User-Agent'] = user_agent_string
226
+ request
227
+ end
228
+
229
+ # Internal: Handle a response for #retrieve
230
+ def retrieve_handle_response(response, timestamp, nonce, accessor_secret)
231
+ case response
232
+ when Net::HTTPSuccess
233
+ # Parse the HTTP response and convert it into a Symbol-keyed Hash
234
+ tuples = Protocol.parse_url_query_string(response.body)
235
+ # Use the parsed response to construct the AccessToken
236
+ access_token = AccessToken.new(
237
+ accessor_secret: accessor_secret,
238
+ consumer_key: @consumer_key,
239
+ expires_at: timestamp + tuples[:oauth_expires_in].to_i,
240
+ nonce: nonce,
241
+ timestamp: timestamp,
242
+ token: tuples[:oauth_token],
243
+ token_secret: tuples[:oauth_token_secret]
244
+ )
245
+ access_token
246
+ else
247
+ # Extract any OAuth Problems reported in the response
248
+ oauth_data = Protocol.parse_authorization_header(response['WWW-Authenticate'])
249
+ # Raise an error for a failure to acquire a token
250
+ raise OAuthError.new('unable to acquire token', response.code, oauth_data[:oauth_problem])
251
+ end
252
+ end
253
+
254
+ # Internal: Prepare a request for #retrieve_keys
255
+ def retrieve_keys_prepare_request(keys_version)
256
+ request = Net::HTTP::Get.new("#{@access_token_url}/keys/#{keys_version}")
257
+ request['Accept'] = 'application/json'
258
+ request['User-Agent'] = user_agent_string
259
+ request['Authorization'] = retrieve.authorization_header
260
+ request
261
+ end
262
+
263
+ # Internal: Handle a response for #retrieve_keys
264
+ def retrieve_keys_handle_response(keys_version, response)
265
+ case response
266
+ when Net::HTTPSuccess
267
+ parsed_response = JSON.parse(response.body)
268
+ aes_key = parsed_response.dig('aesKey', 'secretKey')
269
+ raise OAuthError, 'AES secret key retrieved was invalid' unless aes_key
270
+ rsa_key = parsed_response.dig('rsaKey', 'publicKey')
271
+ raise OAuthError, 'RSA public key retrieved was invalid' unless rsa_key
272
+ Keys.new(
273
+ version: keys_version,
274
+ aes_secret_key: Base64.decode64(aes_key),
275
+ rsa_public_key: Base64.decode64(rsa_key)
276
+ )
277
+ else
278
+ # Extract any OAuth Problems reported in the response
279
+ oauth_data = Protocol.parse_authorization_header(response['WWW-Authenticate'])
280
+ # Raise an error for a failure to acquire keys
281
+ raise OAuthError.new('unable to acquire keys', response.code, oauth_data[:oauth_problem])
282
+ end
283
+ end
284
+ end
178
285
  end
179
286
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerner
4
+ module OAuth1a
5
+ # Internal: A simple cache abstraction for use by AccessTokenAgent only.
6
+ class Cache
7
+ # Internal: A cache entry class for Keys values.
8
+ class KeysEntry
9
+ attr_reader :value
10
+
11
+ def initialize(keys, expires_in)
12
+ @value = keys
13
+ @expires_at = Time.now.utc.to_i + expires_in
14
+ end
15
+
16
+ def expired?(now)
17
+ @expires_at <= now
18
+ end
19
+ end
20
+
21
+ # Internal: A cache entry class for AccessToken values.
22
+ class AccessTokenEntry
23
+ attr_reader :value
24
+
25
+ def initialize(access_token)
26
+ @value = access_token
27
+ end
28
+
29
+ def expired?(now)
30
+ @value.expired?(now: now)
31
+ end
32
+ end
33
+
34
+ ONE_HOUR = 3600
35
+ TWENTY_FOUR_HOURS = 24 * ONE_HOUR
36
+
37
+ def initialize(max:)
38
+ @max = max
39
+ @lock = Mutex.new
40
+ @entries = {}
41
+ end
42
+
43
+ def put(key, entry)
44
+ @lock.synchronize do
45
+ now = Time.now.utc.to_i
46
+ prune_expired(now)
47
+ @entries[key] = entry
48
+ prune_size
49
+ end
50
+ end
51
+
52
+ def get(key)
53
+ @lock.synchronize do
54
+ prune_expired(Time.now.utc.to_i)
55
+ @entries[key]
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def prune_expired(now)
62
+ return if @entries.empty?
63
+
64
+ @entries.delete_if { |_, v| v.expired?(now) }
65
+
66
+ nil
67
+ end
68
+
69
+ def prune_size
70
+ return if @entries.empty? || @entries.size <= @max
71
+
72
+ num_to_prune = @entries.size - @max
73
+ num_to_prune.times { @entries.shift }
74
+
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ module Cerner
8
+ module OAuth1a
9
+ # Public: Keys for authenticating Access Tokens by service providers. Keys can be retrieved
10
+ # via AccessTokenAgent#retrieve_keys.
11
+ class Keys
12
+ # Returns the String version identifier of the keys.
13
+ attr_reader :version
14
+ # Returns the String AES secret key.
15
+ attr_reader :aes_secret_key
16
+ # Returns the String RSA public key.
17
+ attr_reader :rsa_public_key
18
+
19
+ # Public: Constructs an instance.
20
+ #
21
+ # arguments - The keyword arguments of the method:
22
+ # :version - The version identifier of the keys.
23
+ # :aes_secret_key - The AES secret key.
24
+ # :rsa_public_key - The RSA public key.
25
+ #
26
+ # Raises ArgumentError if version, aes_secret_key or rsa_public_key is nil.
27
+ def initialize(version:, aes_secret_key:, rsa_public_key:)
28
+ raise ArgumentError, 'version is nil' unless version
29
+ raise ArgumentError, 'aes_secret_key is nil' unless aes_secret_key
30
+ raise ArgumentError, 'rsa_public_key is nil' unless rsa_public_key
31
+
32
+ @version = version
33
+ @aes_secret_key = aes_secret_key
34
+ @rsa_public_key = rsa_public_key
35
+ end
36
+
37
+ # Public: Compare this to other based on attributes.
38
+ #
39
+ # other - The Keys to compare this to.
40
+ #
41
+ # Return true if equal; false otherwise
42
+ def ==(other)
43
+ version == other.version &&
44
+ aes_secret_key == other.aes_secret_key &&
45
+ rsa_public_key == other.rsa_public_key
46
+ end
47
+
48
+ # Public: Compare this to other based on attributes.
49
+ #
50
+ # other - The Keys to compare this to.
51
+ #
52
+ # Return true if equal; false otherwise
53
+ def eql?(other)
54
+ self == other
55
+ end
56
+
57
+ # Public: Generates a Hash of the attributes.
58
+ #
59
+ # Returns a Hash with keys for each attribute.
60
+ def to_h
61
+ {
62
+ version: @version,
63
+ aes_secret_key: @aes_secret_key,
64
+ rsa_public_key: @rsa_public_key
65
+ }
66
+ end
67
+
68
+ # Public: Returns the #rsa_public_key as an OpenSSL::PKey::RSA intance.
69
+ #
70
+ # Raises OpenSSL::PKey::RSAError if #rsa_public_key is not a valid key
71
+ def rsa_public_key_as_pkey
72
+ OpenSSL::PKey::RSA.new(@rsa_public_key)
73
+ end
74
+
75
+ # Public: Verifies that an oauth_token is authentic based on the #rsa_public_key.
76
+ #
77
+ # oauth_token - The oauth_token value to verify.
78
+ #
79
+ # Returns true if authentic; false otherwise.
80
+ #
81
+ # Raises ArgumentError if oauth_token is nil or invalid
82
+ # Raises OpenSSL::PKey::RSAError if #rsa_public_key is not a valid key
83
+ def verify_rsasha1_signature(oauth_token)
84
+ raise ArgumentError, 'oauth_token is nil' unless oauth_token
85
+
86
+ message, raw_sig = oauth_token.split('&RSASHA1=')
87
+ raise ArgumentError, 'unable to get message out of oauth_token' unless message
88
+ raise ArgumentError, 'unable to get RSASHA1 signature out of oauth_token' unless raw_sig
89
+
90
+ # URL decode value and Base64 (urlsafe) decode that result
91
+ sig = Base64.urlsafe_decode64(URI.decode_www_form_component(raw_sig))
92
+ rsa_public_key_as_pkey.verify(OpenSSL::Digest::SHA1.new, sig, message)
93
+ end
94
+
95
+ # Public: Decrypts the HMACSecrets parameter of an oauth_token using the #aes_secret_key.
96
+ #
97
+ # hmac_secrets_param - The extracted value of the HMACSecrets parameter of an oauth_token. The
98
+ # value is assumed to be Base64 (URL safe) encoded.
99
+ #
100
+ # Returns the decrypted secrets.
101
+ #
102
+ # Raises ArgumentError if oauth_token is nil or invalid
103
+ def decrypt_hmac_secrets(hmac_secrets_param)
104
+ raise ArgumentError, 'hmac_secrets_param is nil' unless hmac_secrets_param
105
+
106
+ ciphertext = Base64.urlsafe_decode64(hmac_secrets_param)
107
+ raise ArgumentError, 'hmac_secrets_param does not contain enough data' unless ciphertext.size > 16
108
+
109
+ # extract first 16 bytes to get initialization vector
110
+ iv = ciphertext[0, 16]
111
+ # trim off the IV
112
+ ciphertext = ciphertext[16..-1]
113
+
114
+ cipher = OpenSSL::Cipher.new('AES-128-CBC')
115
+ # invoke #decrypt to prep the instance
116
+ cipher.decrypt
117
+ cipher.iv = iv
118
+ cipher.key = @aes_secret_key
119
+ text = cipher.update(ciphertext) + cipher.final
120
+ text
121
+ end
122
+ end
123
+ end
124
+ end