cerner-oauth1a 1.0.1 → 2.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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