File: credentials_retriever.rb

package info (click to toggle)
ruby-mongo 2.23.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 15,020 kB
  • sloc: ruby: 110,810; makefile: 5
file content (410 lines) | stat: -rw-r--r-- 17,096 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# frozen_string_literal: true
# rubocop:todo all

# Copyright (C) 2020 MongoDB Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module Mongo
  module Auth
    class Aws
      # Raised when trying to authorize with an invalid configuration
      #
      # @api private
      class CredentialsNotFound < Mongo::Error::AuthError
        def initialize
          super("Could not locate AWS credentials (checked Client URI and Ruby options, environment variables, ECS and EC2 metadata, and Web Identity)")
        end
      end

      # Retrieves AWS credentials from a variety of sources.
      #
      # This class provides for AWS credentials retrieval from:
      # - the passed user (which receives the credentials passed to the
      #   client via URI options and Ruby options)
      # - AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
      #   environment variables (commonly used by AWS SDKs and various tools,
      #   as well as AWS Lambda)
      # - AssumeRoleWithWebIdentity API call
      # - EC2 metadata endpoint
      # - ECS metadata endpoint
      #
      # The sources listed above are consulted in the order specified.
      # The first source that contains any of the three credential components
      # (access key id, secret access key or session token) is used.
      # The credential components must form a valid set if any of the components
      # is specified; meaning, access key id and secret access key must
      # always be provided together, and if a session token is provided
      # the key id and secret key must also be provided. If a source provides
      # partial credentials, credential retrieval fails with an exception.
      #
      # @api private
      class CredentialsRetriever
        # Timeout for metadata operations, in seconds.
        #
        # The auth spec suggests a 10 second timeout but this seems
        # excessively long given that the endpoint is essentially local.
        METADATA_TIMEOUT = 5

        # @param [ Auth::User | nil ] user The user object, if one was provided.
        # @param [ Auth::Aws::CredentialsCache ] credentials_cache The credentials cache.
        def initialize(user = nil, credentials_cache: CredentialsCache.instance)
          @user = user
          @credentials_cache = credentials_cache
        end

        # @return [ Auth::User | nil ] The user object, if one was provided.
        attr_reader :user

        # Retrieves a valid set of credentials, if possible, or raises
        # Auth::InvalidConfiguration.
        #
        # @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout, if any.
        #
        # @return [ Auth::Aws::Credentials ] A valid set of credentials.
        #
        # @raise Auth::InvalidConfiguration if a source contains an invalid set
        #   of credentials.
        # @raise Auth::Aws::CredentialsNotFound if credentials could not be
        #   retrieved from any source.
        # @raise Error::TimeoutError if credentials cannot be retrieved within
        #   the timeout defined on the operation context.
        def credentials(timeout_holder = nil)
          credentials = credentials_from_user(user)
          return credentials unless credentials.nil?

          credentials = credentials_from_environment
          return credentials unless credentials.nil?

          credentials = @credentials_cache.fetch { obtain_credentials_from_endpoints(timeout_holder) }
          return credentials unless credentials.nil?

          raise Auth::Aws::CredentialsNotFound
        end

        private

        # Returns credentials from the user object.
        #
        # @param [ Auth::User | nil ] user The user object, if one was provided.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #
        # @raise Auth::InvalidConfiguration if a source contains an invalid set
        #   of credentials.
        def credentials_from_user(user)
          return nil unless user

          credentials = Credentials.new(
            user.name,
            user.password,
            user.auth_mech_properties['aws_session_token']
          )
          return credentials if credentials_valid?(credentials, 'Mongo::Client URI or Ruby options')
        end

        # Returns credentials from environment variables.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if retrieval failed or the obtained credentials are invalid.
        #
        # @raise Auth::InvalidConfiguration if a source contains an invalid set
        #   of credentials.
        def credentials_from_environment
          credentials = Credentials.new(
            ENV['AWS_ACCESS_KEY_ID'],
            ENV['AWS_SECRET_ACCESS_KEY'],
            ENV['AWS_SESSION_TOKEN']
          )
          credentials if credentials && credentials_valid?(credentials, 'environment variables')
        end

        # Returns credentials from the AWS metadata endpoints.
        #
        # @param [ CsotTimeoutHolder ] timeout_holder CSOT timeout.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if retrieval failed or the obtained credentials are invalid.
        #
        # @raise Auth::InvalidConfiguration if a source contains an invalid set
        #   of credentials.
        # @ raise Error::TimeoutError if credentials cannot be retrieved within
        #   the timeout defined on the operation context.
        def obtain_credentials_from_endpoints(timeout_holder = nil)
          if (credentials = web_identity_credentials(timeout_holder)) && credentials_valid?(credentials, 'Web identity token')
            credentials
          elsif (credentials = ecs_metadata_credentials(timeout_holder)) && credentials_valid?(credentials, 'ECS task metadata')
            credentials
          elsif (credentials = ec2_metadata_credentials(timeout_holder)) && credentials_valid?(credentials, 'EC2 instance metadata')
            credentials
          end
        end

        # Returns credentials from the EC2 metadata endpoint. The credentials
        # could be empty, partial or invalid.
        #
        # @param [ CsotTimeoutHolder ] timeout_holder CSOT timeout.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if retrieval failed.
        # @ raise Error::TimeoutError if credentials cannot be retrieved within
        #   the timeout.
        def ec2_metadata_credentials(timeout_holder = nil)
          timeout_holder&.check_timeout!
          http = Net::HTTP.new('169.254.169.254')
          req = Net::HTTP::Put.new('/latest/api/token',
            # The TTL is required in order to obtain the metadata token.
            {'x-aws-ec2-metadata-token-ttl-seconds' => '30'})
          resp = with_timeout(timeout_holder) do
            http.request(req)
          end
          if resp.code != '200'
            return nil
          end
          metadata_token = resp.body
          resp = with_timeout(timeout_holder) do
            http_get(http, '/latest/meta-data/iam/security-credentials', metadata_token)
          end
          if resp.code != '200'
            return nil
          end
          role_name = resp.body
          escaped_role_name = CGI.escape(role_name).gsub('+', '%20')
          resp = with_timeout(timeout_holder) do
            http_get(http, "/latest/meta-data/iam/security-credentials/#{escaped_role_name}", metadata_token)
          end
          if resp.code != '200'
            return nil
          end
          payload = JSON.parse(resp.body)
          unless payload['Code'] == 'Success'
            return nil
          end
          Credentials.new(
            payload['AccessKeyId'],
            payload['SecretAccessKey'],
            payload['Token'],
            DateTime.parse(payload['Expiration']).to_time
          )
        # When trying to use the EC2 metadata endpoint on ECS:
        # Errno::EINVAL: Failed to open TCP connection to 169.254.169.254:80 (Invalid argument - connect(2) for "169.254.169.254" port 80)
        rescue ::Timeout::Error, IOError, SystemCallError, TypeError
          return nil
        end

        # Returns credentials from the ECS metadata endpoint. The credentials
        # could be empty, partial or invalid.
        #
        # @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if retrieval failed.
        # @ raise Error::TimeoutError if credentials cannot be retrieved within
        #   the timeout defined on the operation context.
        def ecs_metadata_credentials(timeout_holder = nil)
          timeout_holder&.check_timeout!
          relative_uri = ENV['AWS_CONTAINER_CREDENTIALS_RELATIVE_URI']
          if relative_uri.nil? || relative_uri.empty?
            return nil
          end

          http = Net::HTTP.new('169.254.170.2')
          # Per https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html
          # the value in AWS_CONTAINER_CREDENTIALS_RELATIVE_URI includes
          # the leading slash.
          # The current language in MONGODB-AWS specification implies that
          # a leading slash must be added by the driver, but this is not
          # in fact needed.
          req = Net::HTTP::Get.new(relative_uri)
          resp = with_timeout(timeout_holder) do
            http.request(req)
          end
          if resp.code != '200'
            return nil
          end
          payload = JSON.parse(resp.body)
          Credentials.new(
            payload['AccessKeyId'],
            payload['SecretAccessKey'],
            payload['Token'],
            DateTime.parse(payload['Expiration']).to_time
          )
        rescue ::Timeout::Error, IOError, SystemCallError, TypeError
          return nil
        end

        # Returns credentials associated with web identity token that is
        # stored in a file. This authentication mechanism is used to authenticate
        # inside EKS. See https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html
        # for further details.
        #
        # @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if retrieval failed.
        def web_identity_credentials(timeout_holder = nil)
          web_identity_token, role_arn, role_session_name = prepare_web_identity_inputs
          return nil if web_identity_token.nil?
          response = request_web_identity_credentials(
            web_identity_token, role_arn, role_session_name, timeout_holder
          )
          return if response.nil?
          credentials_from_web_identity_response(response)
        end

        # Returns inputs for the AssumeRoleWithWebIdentity AWS API call.
        #
        # @return [ Array<String | nil, String | nil, String | nil> ] Web
        #   identity token, role arn, and role session name.
        def prepare_web_identity_inputs
          token_file = ENV['AWS_WEB_IDENTITY_TOKEN_FILE']
          role_arn = ENV['AWS_ROLE_ARN']
          if token_file.nil? || role_arn.nil?
            return nil
          end
          web_identity_token = File.open(token_file).read
          role_session_name = ENV['AWS_ROLE_SESSION_NAME']
          if role_session_name.nil?
            role_session_name = "ruby-app-#{SecureRandom.alphanumeric(50)}"
          end
          [web_identity_token, role_arn, role_session_name]
        rescue Errno::ENOENT, IOError, SystemCallError
          nil
        end

        # Calls AssumeRoleWithWebIdentity to obtain credentials for the
        # given web identity token.
        #
        # @param [ String ] token The OAuth 2.0 access token or
        #   OpenID Connect ID token that is provided by the identity provider.
        # @param [ String ] role_arn The Amazon Resource Name (ARN) of the role
        #   that the caller is assuming.
        # @param [ String ] role_session_name An identifier for the assumed
        #   role session.
        # @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
        #
        # @return [ Net::HTTPResponse | nil ] AWS API response if successful,
        #   otherwise nil.
        #
        # @ raise Error::TimeoutError if credentials cannot be retrieved within
        #   the timeout defined on the operation context.
        def request_web_identity_credentials(token, role_arn, role_session_name, timeout_holder)
          timeout_holder&.check_timeout!
          uri = URI('https://sts.amazonaws.com/')
          params = {
            'Action' => 'AssumeRoleWithWebIdentity',
            'Version' => '2011-06-15',
            'RoleArn' => role_arn,
            'WebIdentityToken' => token,
            'RoleSessionName' => role_session_name
          }
          uri.query = ::URI.encode_www_form(params)
          req = Net::HTTP::Post.new(uri)
          req['Accept'] = 'application/json'
          resp = with_timeout(timeout_holder) do
            Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |https|
              https.request(req)
            end
          end
          if resp.code != '200'
            return nil
          end
          resp
        rescue Errno::ENOENT, IOError, SystemCallError
          nil
        end

        # Extracts credentials from AssumeRoleWithWebIdentity response.
        #
        # @param [ Net::HTTPResponse ] response AssumeRoleWithWebIdentity
        #   call response.
        #
        # @return [ Auth::Aws::Credentials | nil ] A set of credentials, or nil
        #   if response parsing failed.
        def credentials_from_web_identity_response(response)
          payload = JSON.parse(response.body).dig(
            'AssumeRoleWithWebIdentityResponse',
            'AssumeRoleWithWebIdentityResult',
            'Credentials'
          ) || {}
          Credentials.new(
            payload['AccessKeyId'],
            payload['SecretAccessKey'],
            payload['SessionToken'],
            Time.at(payload['Expiration'])
          )
        rescue JSON::ParserError, TypeError
          nil
        end

        def http_get(http, uri, metadata_token)
          req = Net::HTTP::Get.new(uri,
            {'x-aws-ec2-metadata-token' => metadata_token})
          http.request(req)
        end

        # Checks whether the credentials provided are valid.
        #
        # Returns true if they are valid, false if they are empty, and
        # raises Auth::InvalidConfiguration if the credentials are
        # incomplete (i.e. some of the components are missing).
        def credentials_valid?(credentials, source)
          unless credentials.access_key_id || credentials.secret_access_key ||
            credentials.session_token
          then
            return false
          end

          if credentials.access_key_id || credentials.secret_access_key
            if credentials.access_key_id && !credentials.secret_access_key
              raise Auth::InvalidConfiguration,
                "Access key ID is provided without secret access key (source: #{source})"
            end

            if credentials.secret_access_key && !credentials.access_key_id
              raise Auth::InvalidConfiguration,
                "Secret access key is provided without access key ID (source: #{source})"
            end

          elsif credentials.session_token
            raise Auth::InvalidConfiguration,
              "Session token is provided without access key ID or secret access key (source: #{source})"
          end

          true
        end

        # Execute the given block considering the timeout defined on the context,
        # or the default timeout value.
        #
        # We use +Timeout.timeout+ here because there is no other acceptable easy
        # way to time limit http requests.
        #
        # @param [ CsotTimeoutHolder | nil ] timeout_holder CSOT timeout.
        #
        # @ raise Error::TimeoutError if deadline exceeded.
        def with_timeout(timeout_holder)
          timeout = timeout_holder&.remaining_timeout_sec! || METADATA_TIMEOUT
          exception_class = if timeout_holder&.csot?
                              Error::TimeoutError
                            else
                              nil
                            end
          ::Timeout.timeout(timeout, exception_class) do
            yield
          end
        end
      end
    end
  end
end