File: aws_credentials.rb

package info (click to toggle)
ruby-googleauth 1.16.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 492 kB
  • sloc: ruby: 3,194; makefile: 4
file content (445 lines) | stat: -rw-r--r-- 18,547 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
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# Copyright 2023 Google, 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.

require "time"
require "googleauth/errors"
require "googleauth/external_account/base_credentials"
require "googleauth/external_account/external_account_utils"

module Google
  # Module Auth provides classes that provide Google-specific authorization used to access Google APIs.
  module Auth
    # Authenticates requests using External Account credentials, such as those provided by the AWS provider.
    module ExternalAccount
      # This module handles the retrieval of credentials from Google Cloud by utilizing the AWS EC2 metadata service and
      # then exchanging the credentials for a short-lived Google Cloud access token.
      class AwsCredentials
        # Constant for imdsv2 session token expiration in seconds
        IMDSV2_TOKEN_EXPIRATION_IN_SECONDS = 300

        include Google::Auth::ExternalAccount::BaseCredentials
        include Google::Auth::ExternalAccount::ExternalAccountUtils
        extend CredentialsLoader

        # Will always be nil, but method still gets used.
        attr_reader :client_id

        def initialize options = {}
          base_setup options

          @audience = options[:audience]
          @credential_source = options[:credential_source] || {}
          @environment_id = @credential_source[:environment_id]
          @region_url = @credential_source[:region_url]
          @credential_verification_url = @credential_source[:url]
          @regional_cred_verification_url = @credential_source[:regional_cred_verification_url]
          @imdsv2_session_token_url = @credential_source[:imdsv2_session_token_url]

          # These will be lazily loaded when needed, or will raise an error if not provided
          @region = nil
          @request_signer = nil
          @imdsv2_session_token = nil
          @imdsv2_session_token_expiry = nil
        end

        # Retrieves the subject token using the credential_source object.
        # The subject token is a serialized [AWS GetCallerIdentity signed request](
        #   https://cloud.google.com/iam/docs/access-resources-aws#exchange-token).
        #
        # The logic is summarized as:
        #
        # Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION environment variable or from the AWS
        # metadata server availability-zone if not found in the environment variable.
        #
        # Check AWS credentials in environment variables. If not found, retrieve from the AWS metadata server
        # security-credentials endpoint.
        #
        # When retrieving AWS credentials from the metadata server security-credentials endpoint, the AWS role needs to
        # be determined by # calling the security-credentials endpoint without any argument.
        # Then the credentials can be retrieved via: security-credentials/role_name
        #
        # Generate the signed request to AWS STS GetCallerIdentity action.
        #
        # Inject x-goog-cloud-target-resource into header and serialize the signed request.
        # This will be the subject-token to pass to GCP STS.
        #
        # @return [string] The retrieved subject token.
        #
        def retrieve_subject_token!
          if @request_signer.nil?
            @region = region
            @request_signer = AwsRequestSigner.new @region
          end

          request = {
            method: "POST",
            url: @regional_cred_verification_url.sub("{region}", @region)
          }

          request_options = @request_signer.generate_signed_request fetch_security_credentials, request

          request_headers = request_options[:headers]
          request_headers["x-goog-cloud-target-resource"] = @audience

          aws_signed_request = {
            headers: [],
            method: request_options[:method],
            url: request_options[:url]
          }

          aws_signed_request[:headers] = request_headers.keys.sort.map do |key|
            { key: key, value: request_headers[key] }
          end

          uri_escape aws_signed_request.to_json
        end

        private

        # Retrieves an IMDSv2 session token or returns a cached token if valid
        #
        # @return [String] The IMDSv2 session token
        # @raise [Google::Auth::CredentialsError] If the token URL is missing or there's an error retrieving the token
        def imdsv2_session_token
          return @imdsv2_session_token unless imdsv2_session_token_invalid?
          if @imdsv2_session_token_url.nil?
            raise CredentialsError.with_details(
              "IMDSV2 token url must be provided",
              credential_type_name: self.class.name,
              principal: principal
            )
          end
          begin
            response = connection.put @imdsv2_session_token_url do |req|
              req.headers["x-aws-ec2-metadata-token-ttl-seconds"] = IMDSV2_TOKEN_EXPIRATION_IN_SECONDS.to_s
            end
            raise Faraday::Error unless response.success?
          rescue Faraday::Error => e
            raise CredentialsError.with_details(
              "Fetching AWS IMDSV2 token error: #{e}",
              credential_type_name: self.class.name,
              principal: principal
            )
          end

          @imdsv2_session_token = response.body
          @imdsv2_session_token_expiry = Time.now + IMDSV2_TOKEN_EXPIRATION_IN_SECONDS
          @imdsv2_session_token
        end

        def imdsv2_session_token_invalid?
          return true if @imdsv2_session_token.nil?
          @imdsv2_session_token_expiry.nil? || @imdsv2_session_token_expiry < Time.now
        end

        # Makes a request to an AWS resource endpoint
        #
        # @param [String] url The AWS endpoint URL
        # @param [String] name Resource name for error messages
        # @param [Hash, nil] data Optional data to send in POST requests
        # @param [Hash] headers Optional request headers
        # @return [Faraday::Response] The successful response
        # @raise [Google::Auth::CredentialsError] If the request fails
        def get_aws_resource url, name, data: nil, headers: {}
          begin
            headers["x-aws-ec2-metadata-token"] = imdsv2_session_token
            response = if data
                         headers["Content-Type"] = "application/json"
                         connection.post url, data, headers
                       else
                         connection.get url, nil, headers
                       end
            raise Faraday::Error unless response.success?
            response
          rescue Faraday::Error
            raise CredentialsError.with_details(
              "Failed to retrieve AWS #{name}.",
              credential_type_name: self.class.name,
              principal: principal
            )
          end
        end

        def uri_escape string
          if string.nil?
            nil
          else
            CGI.escape(string.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~")
          end
        end

        # Retrieves the AWS security credentials required for signing AWS requests from either the AWS security
        # credentials environment variables or from the AWS metadata server.
        def fetch_security_credentials
          env_aws_access_key_id = ENV[CredentialsLoader::AWS_ACCESS_KEY_ID_VAR]
          env_aws_secret_access_key = ENV[CredentialsLoader::AWS_SECRET_ACCESS_KEY_VAR]
          # This is normally not available for permanent credentials.
          env_aws_session_token = ENV[CredentialsLoader::AWS_SESSION_TOKEN_VAR]

          if env_aws_access_key_id && env_aws_secret_access_key
            return {
              access_key_id: env_aws_access_key_id,
              secret_access_key: env_aws_secret_access_key,
              session_token: env_aws_session_token
            }
          end

          role_name = fetch_metadata_role_name
          credentials = fetch_metadata_security_credentials role_name

          {
            access_key_id: credentials["AccessKeyId"],
            secret_access_key: credentials["SecretAccessKey"],
            session_token: credentials["Token"]
          }
        end

        # Retrieves the AWS role currently attached to the current AWS workload by querying the AWS metadata server.
        # This is needed for the AWS metadata server security credentials endpoint in order to retrieve the AWS security
        # credentials needed to sign requests to AWS APIs.
        #
        # @return [String] The AWS role name
        # @raise [Google::Auth::CredentialsError] If the credential verification URL is not set or if the request fails
        def fetch_metadata_role_name
          unless @credential_verification_url
            raise CredentialsError.with_details(
              "Unable to determine the AWS metadata server security credentials endpoint",
              credential_type_name: self.class.name,
              principal: principal
            )
          end

          get_aws_resource(@credential_verification_url, "IAM Role").body
        end

        # Retrieves the AWS security credentials required for signing AWS requests from the AWS metadata server.
        def fetch_metadata_security_credentials role_name
          response = get_aws_resource "#{@credential_verification_url}/#{role_name}", "credentials"
          MultiJson.load response.body
        end

        # Reads the name of the AWS region from the environment
        #
        # @return [String] The name of the AWS region
        # @raise [Google::Auth::CredentialsError] If the region is not set in the environment
        #   and the region_url was not set in credentials source
        def region
          @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR]

          unless @region
            unless @region_url
              raise CredentialsError.with_details(
                "region_url or region must be set for external account credentials",
                credential_type_name: self.class.name,
                principal: principal
              )
            end

            @region ||= get_aws_resource(@region_url, "region").body[0..-2]
          end

          @region
        end
      end

      # Implements an AWS request signer based on the AWS Signature Version 4 signing process.
      # https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
      class AwsRequestSigner
        # Instantiates an AWS request signer used to compute authenticated signed requests to AWS APIs based on the AWS
        # Signature Version 4 signing process.
        #
        # @param [string] region_name
        #     The AWS region to use.
        def initialize region_name
          @region_name = region_name
        end

        # Generates an AWS signature version 4 signed request.
        #
        # Creates a signed request following the AWS Signature Version 4 process, which
        # provides secure authentication for AWS API calls. The process includes creating
        # canonical request strings, calculating signatures using the AWS credentials, and
        # building proper authorization headers.
        #
        # For detailed information on the signing process, see:
        # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
        #
        # @param [Hash] aws_credentials The AWS security credentials with the following keys:
        #   @option aws_credentials [String] :access_key_id The AWS access key ID
        #   @option aws_credentials [String] :secret_access_key The AWS secret access key
        #   @option aws_credentials [String, nil] :session_token Optional AWS session token
        # @param [Hash] original_request The request to sign with the following keys:
        #   @option original_request [String] :url The AWS service URL (must be HTTPS)
        #   @option original_request [String] :method The HTTP method (GET, POST, etc.)
        #   @option original_request [Hash, nil] :headers Optional request headers
        #   @option original_request [String, nil] :data Optional request payload
        #
        # @return [Hash] The signed request with the following keys:
        #   * :url - The original URL as a string
        #   * :headers - A hash of headers with the authorization header added
        #   * :method - The HTTP method
        #   * :data - The request payload (if present)
        #
        # @raise [Google::Auth::CredentialsError] If the AWS service URL is invalid
        #
        def generate_signed_request aws_credentials, original_request
          uri = Addressable::URI.parse original_request[:url]
          unless uri.hostname && uri.scheme == "https"
            # NOTE: We use AwsCredentials name but can't access its principal since AwsRequestSigner
            # is a separate class and not a credential object with access to the audience
            raise CredentialsError.with_details(
              "Invalid AWS service URL",
              credential_type_name: AwsCredentials.name,
              principal: "aws"
            )
          end
          service_name = uri.host.split(".").first

          datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ"
          date = datetime[0, 8]

          headers = aws_headers aws_credentials, original_request, datetime

          request_payload = original_request[:data] || ""
          content_sha256 = sha256_hexdigest request_payload

          canonical_req = canonical_request original_request[:method], uri, headers, content_sha256
          sts = string_to_sign datetime, canonical_req, service_name

          # Authorization header requires everything else to be properly setup in order to be properly
          # calculated.
          headers["Authorization"] = build_authorization_header headers, sts, aws_credentials, service_name, date

          {
            url: uri.to_s,
            headers: headers,
            method: original_request[:method],
            data: (request_payload unless request_payload.empty?)
          }.compact
        end

        private

        def aws_headers aws_credentials, original_request, datetime
          uri = Addressable::URI.parse original_request[:url]
          temp_headers = original_request[:headers] || {}
          headers = {}
          temp_headers.each_key { |k| headers[k.to_s] = temp_headers[k] }
          headers["host"] = uri.host
          headers["x-amz-date"] = datetime
          headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token]
          headers
        end

        def build_authorization_header headers, sts, aws_credentials, service_name, date
          [
            "AWS4-HMAC-SHA256",
            "Credential=#{credential aws_credentials[:access_key_id], date, service_name},",
            "SignedHeaders=#{headers.keys.sort.join ';'},",
            "Signature=#{signature aws_credentials[:secret_access_key], date, sts, service_name}"
          ].join(" ")
        end

        def signature secret_access_key, date, string_to_sign, service
          k_date = hmac "AWS4#{secret_access_key}", date
          k_region = hmac k_date, @region_name
          k_service = hmac k_region, service
          k_credentials = hmac k_service, "aws4_request"

          hexhmac k_credentials, string_to_sign
        end

        def hmac key, value
          OpenSSL::HMAC.digest OpenSSL::Digest.new("sha256"), key, value
        end

        def hexhmac key, value
          OpenSSL::HMAC.hexdigest OpenSSL::Digest.new("sha256"), key, value
        end

        def credential access_key_id, date, service
          "#{access_key_id}/#{credential_scope date, service}"
        end

        def credential_scope date, service
          [
            date,
            @region_name,
            service,
            "aws4_request"
          ].join("/")
        end

        def string_to_sign datetime, canonical_request, service
          [
            "AWS4-HMAC-SHA256",
            datetime,
            credential_scope(datetime[0, 8], service),
            sha256_hexdigest(canonical_request)
          ].join("\n")
        end

        def host uri
          # Handles known and unknown URI schemes; default_port nil when unknown.
          if uri.default_port == uri.port
            uri.host
          else
            "#{uri.host}:#{uri.port}"
          end
        end

        def canonical_request http_method, uri, headers, content_sha256
          headers = headers.sort_by(&:first) # transforms to a sorted array of [key, value]

          [
            http_method,
            uri.path.empty? ? "/" : uri.path,
            build_canonical_querystring(uri.query || ""),
            headers.map { |k, v| "#{k}:#{v}\n" }.join, # Canonical headers
            headers.map(&:first).join(";"), # Signed headers
            content_sha256
          ].join("\n")
        end

        def sha256_hexdigest string
          OpenSSL::Digest::SHA256.hexdigest string
        end

        # Generates the canonical query string given a raw query string.
        # Logic is based on
        # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
        # Code is from the AWS SDK for Ruby
        # https://github.com/aws/aws-sdk-ruby/blob/0ac3d0a393ed216290bfb5f0383380376f6fb1f1/gems/aws-sigv4/lib/aws-sigv4/signer.rb#L532
        def build_canonical_querystring query
          params = query.split "&"
          params = params.map { |p| p.include?("=") ? p : "#{p}=" }

          params.each.with_index.sort do |(a, a_offset), (b, b_offset)|
            a_name, a_value = a.split "="
            b_name, b_value = b.split "="
            if a_name == b_name
              if a_value == b_value
                a_offset <=> b_offset
              else
                a_value <=> b_value
              end
            else
              a_name <=> b_name
            end
          end.map(&:first).join("&")
        end
      end
    end
  end
end