From 5dbc165570ec4c8762efa94a0de0acafef56bc97 Mon Sep 17 00:00:00 2001 From: Robert Clark Date: Thu, 12 May 2022 17:17:46 +0000 Subject: [PATCH 1/5] feat: Add External Account Credential support as well as support for impersonated credential --- lib/googleauth/credentials_loader.rb | 5 + lib/googleauth/default_credentials.rb | 5 + lib/googleauth/external_account.rb | 425 ++++++++++++++++++++++ lib/googleauth/oauth2/sts_client.rb | 94 +++++ spec/googleauth/oauth2/sts_client_spec.rb | 77 ++++ 5 files changed, 606 insertions(+) create mode 100644 lib/googleauth/external_account.rb create mode 100644 lib/googleauth/oauth2/sts_client.rb create mode 100644 spec/googleauth/oauth2/sts_client_spec.rb diff --git a/lib/googleauth/credentials_loader.rb b/lib/googleauth/credentials_loader.rb index 82a479c1..349ef7d6 100644 --- a/lib/googleauth/credentials_loader.rb +++ b/lib/googleauth/credentials_loader.rb @@ -30,6 +30,11 @@ module CredentialsLoader REFRESH_TOKEN_VAR = "GOOGLE_REFRESH_TOKEN".freeze ACCOUNT_TYPE_VAR = "GOOGLE_ACCOUNT_TYPE".freeze PROJECT_ID_VAR = "GOOGLE_PROJECT_ID".freeze + AWS_REGION_VAR = "AWS_REGION".freeze + AWS_DEFAULT_REGION_VAR = "AWS_DEFAULT_REGION".freeze + AWS_ACCESS_KEY_ID_VAR = "AWS_ACCESS_KEY_ID".freeze + AWS_SECRET_ACCESS_KEY_VAR = "AWS_SECRET_ACCESS_KEY".freeze + AWS_SESSION_TOKEN_VAR = "AWS_SESSION_TOKEN".freeze GCLOUD_POSIX_COMMAND = "gcloud".freeze GCLOUD_WINDOWS_COMMAND = "gcloud.cmd".freeze GCLOUD_CONFIG_COMMAND = "config config-helper --format json --verbosity none".freeze diff --git a/lib/googleauth/default_credentials.rb b/lib/googleauth/default_credentials.rb index 1c83490e..b43775ce 100644 --- a/lib/googleauth/default_credentials.rb +++ b/lib/googleauth/default_credentials.rb @@ -18,6 +18,7 @@ require "googleauth/credentials_loader" require "googleauth/service_account" require "googleauth/user_refresh" +require "googleauth/external_account" module Google # Module Auth provides classes that provide Google-specific authorization @@ -53,6 +54,8 @@ def self.read_creds ServiceAccountCredentials when "authorized_user" UserRefreshCredentials + when "external_account" + ExternalAccountCredentials else raise "credentials type '#{type}' is not supported" end @@ -69,6 +72,8 @@ def self.determine_creds_class json_key_io [json_key, ServiceAccountCredentials] when "authorized_user" [json_key, UserRefreshCredentials] + when "external_account" + [json_key, ExternalAccountCredentials] else raise "credentials type '#{type}' is not supported" end diff --git a/lib/googleauth/external_account.rb b/lib/googleauth/external_account.rb new file mode 100644 index 00000000..4f9553e6 --- /dev/null +++ b/lib/googleauth/external_account.rb @@ -0,0 +1,425 @@ +# Copyright 2015 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/oauth2/sts_client" + +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. + class ExternalAccountCredentials + extend CredentialsLoader + attr_reader :project_id + attr_reader :quota_project_id + + # Create a ExternalAccountCredentials + # + # @param json_key_io [IO] an IO from which the JSON key can be read + # @param scope [string|array|nil] the scope(s) to access + def self.make_creds options = {} + json_key_io, scope = options.values_at :json_key_io, :scope + + raise "a json file is required for external account credentials" unless json_key_io + user_creds = read_json_key json_key_io + + AwsCredentials.new( + audience: user_creds["audience"], + scope: scope, + subject_token_type: user_creds["subject_token_type"], + token_url: user_creds["token_url"], + credential_source: user_creds["credential_source"], + service_account_impersonation_url: user_creds["service_account_impersonation_url"], + region: ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR] + ) + end + + # Reads the required fields from the JSON. + def self.read_json_key json_key_io + json_key = MultiJson.load json_key_io.read + wanted = [ + "audience", "subject_token_type", "token_url", "credential_source", "service_account_impersonation_url" + ] + wanted.each do |key| + raise "the json is missing the #{key} field" unless json_key.key? key + end + json_key + end + end + + # 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 + AUTH_METADATA_KEY = :authorization + STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze + STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze + IAM_SCOPE = ["https://www.googleapis.com/auth/iam"].freeze + + def initialize options = {} + @audience = options[:audience] + @scope = options[:scope] || IAM_SCOPE + @subject_token_type = options[:subject_token_type] + @token_url = options[:token_url] + @credential_source = options[:credential_source] || {} + @service_account_impersonation_url = options[:service_account_impersonation_url] + + @environment_id = @credential_source["environment_id"] + @region_url = @credential_source["region_url"] + @credential_source_url = @credential_source["url"] + @regional_cred_verification_url = @credential_source["regional_cred_verification_url"] + + @region = options[:region] || region(options) + @request_signer = AwsRequestSigner.new @region + + @expiry = nil + @access_token = nil + + @sts_client = Google::Auth::OAuth2::STSClient.new token_exchange_endpoint: @token_url + end + + def fetch_access_token! options = {} + credentials = fetch_security_credentials options + + response = exchange_token credentials + + if @service_account_impersonation_url + impersonated_response = get_impersonated_access_token response["access_token"] + @expiry = Time.parse impersonated_response["expireTime"] + @access_token = impersonated_response["accessToken"] + else + # Extract the expiration time in seconds from the response and calculate the actual expiration time + # and then save that to the expiry variable. + @expiry = Time.now.utc + response["expires_in"].to_i + @access_token = response["access_token"] + end + end + + # Whether the id_token or access_token is missing or about to expire. + def needs_access_token? + @access_token.nil? || expires_within?(60) + end + + def expires_within? seconds + @expiry && @expiry - Time.now.utc < seconds + end + + # Updates a_hash updated with the authentication token + def apply! a_hash, opts = {} + # fetch the access token there is currently not one, or if the client + # has expired + fetch_access_token! opts if needs_access_token? + a_hash[AUTH_METADATA_KEY] = "Bearer #{@access_token}" + end + + # Returns a clone of a_hash updated with the authentication token + def apply a_hash, opts = {} + a_copy = a_hash.clone + apply! a_copy, opts + a_copy + end + + private + + def exchange_token credentials + request_options = @request_signer.generate_signed_request( + credentials, + @regional_cred_verification_url.sub("{region}", @region), + "POST" + ) + + 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 + + @sts_client.exchange_token( + audience: @audience, + grant_type: STS_GRANT_TYPE, + subject_token: uri_escape(aws_signed_request.to_json), + subject_token_type: @subject_token_type, + scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope, + requested_token_type: STS_REQUESTED_TOKEN_TYPE + ) + end + + def get_impersonated_access_token token, options = {} + c = options[:connection] || Faraday.default_connection + + response = c.post @service_account_impersonation_url do |req| + req.headers["Authorization"] = "Bearer #{token}" + req.headers["Content-Type"] = "application/json" + req.body = MultiJson.dump({ + scope: @scope + }) + end + + if response.status != 200 + raise "Service account impersonation failed with status #{response.status}" + end + + MultiJson.load response.body + 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 options = {} + 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 options + credentials = fetch_metadata_security_credentials role_name, options + + { + 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. + def fetch_metadata_role_name options = {} + unless @credential_source_url + raise "Unable to determine the AWS metadata server security credentials endpoint" + end + + c = options[:connection] || Faraday.default_connection + response = c.get @credential_source_url + + unless response.success? + raise "Unable to determine the AWS role attached to the current AWS workload" + end + + response.body + end + + # Retrieves the AWS security credentials required for signing AWS + # requests from the AWS metadata server. + def fetch_metadata_security_credentials role_name, options = {} + c = options[:connection] || Faraday.default_connection + + response = c.get "#{@credential_source_url}/#{role_name}", {}, { "Content-Type": "application/json" } + + unless response.success? + raise "Unable to fetch the AWS security credentials required for signing AWS requests" + end + + MultiJson.load response.body + end + + # Region may already be set, if it is then it can just be returned + def region options = {} + unless @region + raise "region_url or region must be set for external account credentials" unless @region_url + + c = options[:connection] || Faraday.default_connection + @region ||= c.get(@region_url).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. + # Args: + # region_name (str): The AWS region to use. + def initialize region_name + @region_name = region_name + end + + # Generates the signed request for the provided HTTP request for calling + # an AWS API. This follows the steps described at: + # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html + # Args: + # aws_security_credentials (Hash[str, str]): A dictionary containing + # the AWS security credentials. + # url (str): The AWS service URL containing the canonical URI and + # query string. + # method (str): The HTTP method used to call this API. + # Returns: + # Hash[str, str]: The AWS signed request dictionary object. + def generate_signed_request aws_credentials, url, method, request_payload = "" + headers = {} + + uri = URI.parse url + + if !uri.hostname || uri.scheme != "https" + raise "Invalid AWS service URL" + end + + service_name = uri.host.split(".").first + + datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ" + date = datetime[0, 8] + + headers["host"] = uri.host + headers["x-amz-date"] = datetime + headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token] + + content_sha256 = sha256_hexdigest request_payload + + canonical_req = canonical_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: method + } + end + + private + + 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.match(/=/) ? p : "#{p}=" } + + params.each.with_index.sort do |a, b| + a, a_offset = a + b, b_offset = b + 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 diff --git a/lib/googleauth/oauth2/sts_client.rb b/lib/googleauth/oauth2/sts_client.rb new file mode 100644 index 00000000..d2cd8e86 --- /dev/null +++ b/lib/googleauth/oauth2/sts_client.rb @@ -0,0 +1,94 @@ +# Copyright 2020 Google LLC +# +# 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 Google + module Auth + module OAuth2 + # OAuth 2.0 Token Exchange Spec. + # This module defines a token exchange utility based on the `OAuth 2.0 Token + # Exchange`_ spec. This will be mainly used to exchange external credentials + # for GCP access tokens in workload identity pools to access Google APIs. + # The implementation will support various types of client authentication as + # allowed in the spec. + # A deviation on the spec will be for additional Google specific options that + # cannot be easily mapped to parameters defined in the RFC. + # The returned dictionary response will be based on the `rfc8693 section 2.2.1`_ + # spec JSON response. + # .. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 + # .. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1 + class STSClient + URLENCODED_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }.freeze + + # Create a new instance of the STSClient. + # + # @param [String] token_exchange_endpoint + # The token exchange endpoint. + def initialize options = {} + @token_exchange_endpoint = options[:token_exchange_endpoint] + end + + # Exchanges the provided token for another type of token based on the + # rfc8693 spec + # + # @param [Faraday instance] connection + # A callable faraday instance used to make HTTP requests. + # @param [String] grant_type + # The OAuth 2.0 token exchange grant type. + # @param [String] subject_token + # The OAuth 2.0 token exchange subject token. + # @param [String] subject_token_type + # The OAuth 2.0 token exchange subject token type. + # @param [String] resource + # The optional OAuth 2.0 token exchange resource field. + # @param [String] audience + # The optional OAuth 2.0 token exchange audience field. + # @param [Array] scopes + # The optional list of scopes to use. + # @param [String] requested_token_type + # The optional OAuth 2.0 token exchange requested token type. + # @param additional_headers (Hash): + # The optional additional headers to pass to the token exchange endpoint. + # + # @return [Hash] A hash containing the token exchange response. + def exchange_token options = {} + missing_required_opts = [:grant_type, :subject_token, :subject_token_type] - options.keys + unless missing_required_opts.empty? + raise ArgumentError, "Missing required options: #{missing_required_opts.join ', '}" + end + + c = options[:connection] || Faraday.default_connection + + headers = URLENCODED_HEADERS.dup.merge(options[:additional_headers] || {}) + + request_body = { + grant_type: options[:grant_type], + audience: options[:audience], + scope: options[:scopes]&.join(" ") || [], + requested_token_type: options[:requested_token_type], + subject_token: options[:subject_token], + subject_token_type: options[:subject_token_type] + } + + response = c.post @token_exchange_endpoint, URI.encode_www_form(request_body), headers + + if response.status != 200 + raise "Token exchange failed with status #{response.status}" + end + + MultiJson.load response.body + end + end + end + end +end diff --git a/spec/googleauth/oauth2/sts_client_spec.rb b/spec/googleauth/oauth2/sts_client_spec.rb new file mode 100644 index 00000000..1efe1bb0 --- /dev/null +++ b/spec/googleauth/oauth2/sts_client_spec.rb @@ -0,0 +1,77 @@ +# Copyright 2015 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 "googleauth" +require "googleauth/oauth2/sts_client" +require "spec_helper" + +spec_dir = File.expand_path File.join(File.dirname(__FILE__)) +$LOAD_PATH.unshift spec_dir +$LOAD_PATH.uniq! + +describe Google::Auth::OAuth2::STSClient do + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze + RESOURCE = "https://api.example.com/".freeze + AUDIENCE = "urn:example:cooperation-context".freeze + REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze + SUBJECT_TOKEN = "HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE".freeze + SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt".freeze + TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2".freeze + SUCCESS_RESPONSE = { + "access_token": "ACCESS_TOKEN", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "scope1 scope2", + }.freeze + ERROR_RESPONSE = { + "error": "invalid_request", + "error_description": "Invalid subject token", + "error_uri": "https://tools.ietf.org/html/rfc6749", + }.freeze + + let :sts_client do + Google::Auth::OAuth2::STSClient.new({token_exchange_endpoint: TOKEN_EXCHANGE_ENDPOINT}) + end + + + it 'should successfully exchange a token with only required parameters' do + stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 200, body: SUCCESS_RESPONSE.to_json) + + res = sts_client.exchange_token({ + grant_type: GRANT_TYPE, + subject_token: SUBJECT_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE, + audience: AUDIENCE, + requested_token_type: REQUESTED_TOKEN_TYPE + }) + + expect(res["access_token"]).to eq(SUCCESS_RESPONSE[:access_token]) + end + + it 'should appropriately handle an error response' do + stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 400, body: ERROR_RESPONSE.to_json) + + # Expect an exception to be raised + expect { + sts_client.exchange_token({ + grant_type: GRANT_TYPE, + subject_token: SUBJECT_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE, + audience: AUDIENCE, + requested_token_type: REQUESTED_TOKEN_TYPE + }) + }.to raise_error(/Token exchange failed with status 400/) + end +end From 6935145a5aca00ad1b88e8b40642a60d8a379010 Mon Sep 17 00:00:00 2001 From: Robert Clark Date: Tue, 21 Jun 2022 16:20:26 -0400 Subject: [PATCH 2/5] chore: Add tests for ExternalAccount, add methods required to make test compatible with apply_auth_examples. ExternalAccount was not fully compatible with the other providers, this brings it in line and moves some common methods between the other provides and ExternalAccount into a module which is now included by the AWSClient provider and Signet::Auth::Client --- lib/googleauth/base_client.rb | 76 +++++++++++ lib/googleauth/external_account.rb | 73 +++++++---- lib/googleauth/oauth2/sts_client.rb | 2 +- lib/googleauth/service_account.rb | 2 +- lib/googleauth/signet.rb | 42 +----- spec/googleauth/external_account_spec.rb | 159 +++++++++++++++++++++++ 6 files changed, 288 insertions(+), 66 deletions(-) create mode 100644 lib/googleauth/base_client.rb create mode 100644 spec/googleauth/external_account_spec.rb diff --git a/lib/googleauth/base_client.rb b/lib/googleauth/base_client.rb new file mode 100644 index 00000000..23c4ed2a --- /dev/null +++ b/lib/googleauth/base_client.rb @@ -0,0 +1,76 @@ +# Copyright 2015 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 "os" + +module Google + # Module Auth provides classes that provide Google-specific authorization + # used to access Google APIs. + module Auth + # BaseClient is a class used to contain common methods that are required by any + # Credentials Client, including AwsCredentials, ServiceAccountCredentials, + # and UserRefreshCredentials. + module BaseClient + AUTH_METADATA_KEY = :authorization + + # Updates a_hash updated with the authentication token + def apply! a_hash, opts = {} + # fetch the access token there is currently not one, or if the client + # has expired + fetch_access_token! opts if needs_access_token? + a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}" + end + + # Returns a clone of a_hash updated with the authentication token + def apply a_hash, opts = {} + a_copy = a_hash.clone + apply! a_copy, opts + a_copy + end + + # Whether the id_token or access_token is missing or about to expire. + def needs_access_token? + send(token_type).nil? || expires_within?(60) + end + + # Returns a reference to the #apply method, suitable for passing as + # a closure + def updater_proc + proc { |a_hash, opts = {}| apply a_hash, opts } + end + + def on_refresh &block + @refresh_listeners = [] unless defined? @refresh_listeners + @refresh_listeners << block + end + + def notify_refresh_listeners + listeners = defined?(@refresh_listeners) ? @refresh_listeners : [] + listeners.each do |block| + block.call self + end + end + + def expires_within? + raise "This method must be implemented by a subclass" + end + + private + + def token_type + raise "This method must be implemented by a subclass" + end + end + end +end diff --git a/lib/googleauth/external_account.rb b/lib/googleauth/external_account.rb index 4f9553e6..7a35906b 100644 --- a/lib/googleauth/external_account.rb +++ b/lib/googleauth/external_account.rb @@ -13,6 +13,8 @@ # limitations under the License. require "time" +require "googleauth/credentials_loader" +require "googleauth/base_client" require "googleauth/oauth2/sts_client" module Google @@ -51,7 +53,7 @@ def self.make_creds options = {} def self.read_json_key json_key_io json_key = MultiJson.load json_key_io.read wanted = [ - "audience", "subject_token_type", "token_url", "credential_source", "service_account_impersonation_url" + "audience", "subject_token_type", "token_url", "credential_source" ] wanted.each do |key| raise "the json is missing the #{key} field" unless json_key.key? key @@ -64,7 +66,8 @@ def self.read_json_key json_key_io # by utilizing the AWS EC2 metadata service and then exchanging the # credentials for a short-lived Google Cloud access token. class AwsCredentials - AUTH_METADATA_KEY = :authorization + include BaseClient + STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze IAM_SCOPE = ["https://www.googleapis.com/auth/iam"].freeze @@ -85,7 +88,7 @@ def initialize options = {} @region = options[:region] || region(options) @request_signer = AwsRequestSigner.new @region - @expiry = nil + @expires_at = nil @access_token = nil @sts_client = Google::Auth::OAuth2::STSClient.new token_exchange_endpoint: @token_url @@ -98,42 +101,64 @@ def fetch_access_token! options = {} if @service_account_impersonation_url impersonated_response = get_impersonated_access_token response["access_token"] - @expiry = Time.parse impersonated_response["expireTime"] - @access_token = impersonated_response["accessToken"] + self.expires_at = impersonated_response["expireTime"] + self.access_token = impersonated_response["accessToken"] else # Extract the expiration time in seconds from the response and calculate the actual expiration time # and then save that to the expiry variable. - @expiry = Time.now.utc + response["expires_in"].to_i - @access_token = response["access_token"] + self.expires_at = Time.now.utc + response["expires_in"].to_i + self.access_token = response["access_token"] end + + notify_refresh_listeners end - # Whether the id_token or access_token is missing or about to expire. - def needs_access_token? - @access_token.nil? || expires_within?(60) + def notify_refresh_listeners + listeners = defined?(@refresh_listeners) ? @refresh_listeners : [] + listeners.each do |block| + block.call self + end end def expires_within? seconds - @expiry && @expiry - Time.now.utc < seconds + @expires_at && @expires_at - Time.now.utc < seconds + end + + def expires_at + @expires_at + end + + def expires_at= new_expires_at + @expires_at = normalize_timestamp new_expires_at end - # Updates a_hash updated with the authentication token - def apply! a_hash, opts = {} - # fetch the access token there is currently not one, or if the client - # has expired - fetch_access_token! opts if needs_access_token? - a_hash[AUTH_METADATA_KEY] = "Bearer #{@access_token}" + def access_token + @access_token end - # Returns a clone of a_hash updated with the authentication token - def apply a_hash, opts = {} - a_copy = a_hash.clone - apply! a_copy, opts - a_copy + def access_token= new_access_token + @access_token = new_access_token end private + def token_type + :access_token + end + + def normalize_timestamp time + case time + when NilClass + nil + when Time + time + when String + Time.parse time + else + raise "Invalid time value #{time}" + end + end + def exchange_token credentials request_options = @request_signer.generate_signed_request( credentials, @@ -170,9 +195,7 @@ def get_impersonated_access_token token, options = {} response = c.post @service_account_impersonation_url do |req| req.headers["Authorization"] = "Bearer #{token}" req.headers["Content-Type"] = "application/json" - req.body = MultiJson.dump({ - scope: @scope - }) + req.body = MultiJson.dump({ scope: @scope }) end if response.status != 200 diff --git a/lib/googleauth/oauth2/sts_client.rb b/lib/googleauth/oauth2/sts_client.rb index d2cd8e86..04dc215c 100644 --- a/lib/googleauth/oauth2/sts_client.rb +++ b/lib/googleauth/oauth2/sts_client.rb @@ -74,7 +74,7 @@ def exchange_token options = {} request_body = { grant_type: options[:grant_type], audience: options[:audience], - scope: options[:scopes]&.join(" ") || [], + scope: Array(options[:scopes])&.join(" ") || [], requested_token_type: options[:requested_token_type], subject_token: options[:subject_token], subject_token_type: options[:subject_token_type] diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 1b98f15b..6342c2c8 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -130,7 +130,7 @@ def apply_self_signed_jwt! a_hash # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production) class ServiceAccountJwtHeaderCredentials JWT_AUD_URI_KEY = :jwt_aud_uri - AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY + AUTH_METADATA_KEY = Signet::OAuth2::Client::AUTH_METADATA_KEY TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze SIGNING_ALGORITHM = "RS256".freeze EXPIRY = 60 diff --git a/lib/googleauth/signet.rb b/lib/googleauth/signet.rb index 41393eb7..4c5fcc09 100644 --- a/lib/googleauth/signet.rb +++ b/lib/googleauth/signet.rb @@ -13,16 +13,18 @@ # limitations under the License. require "signet/oauth_2/client" +require "googleauth/base_client" module Signet # OAuth2 supports OAuth2 authentication. module OAuth2 - AUTH_METADATA_KEY = :authorization # Signet::OAuth2::Client creates an OAuth2 client # # This reopens Client to add #apply and #apply! methods which update a # hash with the fetched authentication token. class Client + include Google::Auth::BaseClient + def configure_connection options @connection_info = options[:connection_builder] || options[:default_connection] @@ -34,37 +36,6 @@ def token_type target_audience ? :id_token : :access_token end - # Whether the id_token or access_token is missing or about to expire. - def needs_access_token? - send(token_type).nil? || expires_within?(60) - end - - # Updates a_hash updated with the authentication token - def apply! a_hash, opts = {} - # fetch the access token there is currently not one, or if the client - # has expired - fetch_access_token! opts if needs_access_token? - a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}" - end - - # Returns a clone of a_hash updated with the authentication token - def apply a_hash, opts = {} - a_copy = a_hash.clone - apply! a_copy, opts - a_copy - end - - # Returns a reference to the #apply method, suitable for passing as - # a closure - def updater_proc - proc { |a_hash, opts = {}| apply a_hash, opts } - end - - def on_refresh &block - @refresh_listeners = [] unless defined? @refresh_listeners - @refresh_listeners << block - end - alias orig_fetch_access_token! fetch_access_token! def fetch_access_token! options = {} unless options[:connection] @@ -78,13 +49,6 @@ def fetch_access_token! options = {} info end - def notify_refresh_listeners - listeners = defined?(@refresh_listeners) ? @refresh_listeners : [] - listeners.each do |block| - block.call self - end - end - def build_default_connection if !defined?(@connection_info) nil diff --git a/spec/googleauth/external_account_spec.rb b/spec/googleauth/external_account_spec.rb new file mode 100644 index 00000000..160af812 --- /dev/null +++ b/spec/googleauth/external_account_spec.rb @@ -0,0 +1,159 @@ +# Copyright 2015 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. + +spec_dir = File.expand_path File.join(File.dirname(__FILE__)) +$LOAD_PATH.unshift spec_dir +$LOAD_PATH.uniq! + +require "apply_auth_examples" +require "googleauth/external_account" + +include Google::Auth::CredentialsLoader + +describe Google::Auth::ExternalAccountCredentials do + ExternalAccountCredentials = Google::Auth::ExternalAccountCredentials + + let(:aws_metadata_role_name) { "aws-metadata-role" } + let(:aws_region) { "us-east-1c" } + + let :cred_json do + { + type: "external_account", + audience: "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + subject_token_type: "urn:ietf:params:aws:token-type:aws4_request", + service_account_impersonation_url: "https://us-east1-iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-1234@service-name.iam.gserviceaccount.com:generateAccessToken", + token_url: "https://sts.googleapis.com/v1/token", + credential_source: { + environment_id: "aws1", + region_url: "http://169.254.169.254/latest/meta-data/placement/availability-zone", + url: "http://169.254.169.254/latest/meta-data/iam/security-credentials", + regional_cred_verification_url: "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15" + } + } + end + + let :current_datetime do + Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") + end + + let :expiry_datetime do + (Time.now + 3600).utc.strftime("%Y-%m-%dT%H:%M:%SZ") + end + + let :aws_security_credentials_response do + { + Code: "Success", + LastUpdated: current_datetime, + Type: "AWS-HMAC", + AccessKeyId: "test", + SecretAccessKey: "test", + Token: "test", + Expiration: expiry_datetime + } + end + + let :google_token_response do + { + "token_type" => "Bearer", + "expires_in" => 3600, + "issued_token_type" => "urn:ietf:params:aws:token-type:aws4_request" + } + end + + let :google_token_impersonation_response do + { + accessToken: "test", + expireTime: expiry_datetime + } + end + + before :example do + # Stub the region request response that happens duing initialization. This cannot happen as + # part of make_auth_stubs since this request is made during the before block. + stub_request(:get, cred_json.dig(:credential_source, :region_url)) + .to_return(status: 200, body: aws_region, headers: {"Content-Type" => "text/plain"}) + end + + def cred_json_text + MultiJson.dump cred_json + end + + def cred_json_without_impersonation_url_text + MultiJson.dump cred_json_without_impersonation_url + end + + # Stubs the common requests to all external account credential types + def make_auth_stubs opts + # Stub the metadata role name request + stub_request(:get, cred_json.dig(:credential_source, :url)) + .to_return(body: aws_metadata_role_name, + status: 200, + headers: { "Content-Type" => "text/plain" } + ) + + # Stub the AWS security credentials request + stub_request(:get, "#{cred_json.dig(:credential_source, :url)}/#{aws_metadata_role_name}") + .with(headers: { "Content-Type" => "application/json" }) + .to_return( + body: MultiJson.dump(aws_security_credentials_response) + ) + + # Stub the Google token request + response = google_token_response + response["access_token"] = opts[:access_token] if opts[:access_token] + stub_request(:post, cred_json.dig(:token_url)) + .to_return( + body: MultiJson.dump(response) + ) + end + + describe "when a service impersonation URL is provided" do + before :example do + @client = ExternalAccountCredentials.make_creds( + json_key_io: StringIO.new(cred_json_text), + scope: "https://www.googleapis.com/auth/userinfo.profile" + ) + end + + alias :orig_make_auth_stubs :make_auth_stubs + def make_auth_stubs opts + orig_make_auth_stubs opts + + # Stub the Google token impersonation request + response = google_token_impersonation_response + response["accessToken"] = opts[:access_token] if opts[:access_token] + stub_request(:post, cred_json.dig(:service_account_impersonation_url)) + .to_return( + body: MultiJson.dump(response) + ) + end + + it_behaves_like "apply/apply! are OK" + end + + describe "when a service impersonation URL is not provided" do + let :cred_json_without_impersonation_url do + cred_json.dup.tap { |c| c.delete :service_account_impersonation_url } + end + + before :example do + @client = ExternalAccountCredentials.make_creds( + json_key_io: StringIO.new(cred_json_without_impersonation_url_text), + scope: "https://www.googleapis.com/auth/userinfo.profile" + ) + end + + it_behaves_like "apply/apply! are OK" + end +end From ce07c11a951b966cdcdf598db71a377a7745558c Mon Sep 17 00:00:00 2001 From: Robert Clark Date: Fri, 30 Sep 2022 14:43:09 -0400 Subject: [PATCH 3/5] chore: Move AWS Credentials to their own file, do not pass role from external account, create dedicated Connection helper module, other misc PR cleanup --- lib/googleauth/base_client.rb | 2 +- lib/googleauth/external_account.rb | 392 +---------------- .../external_account/aws_credentials.rb | 401 ++++++++++++++++++ lib/googleauth/helpers/connection.rb | 33 ++ lib/googleauth/oauth2/sts_client.rb | 10 +- .../external_account/aws_credentials_spec.rb | 91 ++++ spec/googleauth/external_account_spec.rb | 2 +- spec/googleauth/oauth2/sts_client_spec.rb | 57 +-- 8 files changed, 570 insertions(+), 418 deletions(-) create mode 100644 lib/googleauth/external_account/aws_credentials.rb create mode 100644 lib/googleauth/helpers/connection.rb create mode 100644 spec/googleauth/external_account/aws_credentials_spec.rb diff --git a/lib/googleauth/base_client.rb b/lib/googleauth/base_client.rb index 23c4ed2a..8ecef6cb 100644 --- a/lib/googleauth/base_client.rb +++ b/lib/googleauth/base_client.rb @@ -1,4 +1,4 @@ -# Copyright 2015 Google, Inc. +# Copyright 2022 Google, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/lib/googleauth/external_account.rb b/lib/googleauth/external_account.rb index 7a35906b..45f72d0c 100644 --- a/lib/googleauth/external_account.rb +++ b/lib/googleauth/external_account.rb @@ -14,8 +14,7 @@ require "time" require "googleauth/credentials_loader" -require "googleauth/base_client" -require "googleauth/oauth2/sts_client" +require "googleauth/external_account/aws_credentials" module Google # Module Auth provides classes that provide Google-specific authorization @@ -24,7 +23,6 @@ module Auth # Authenticates requests using External Account credentials, such # as those provided by the AWS provider. class ExternalAccountCredentials - extend CredentialsLoader attr_reader :project_id attr_reader :quota_project_id @@ -38,14 +36,13 @@ def self.make_creds options = {} raise "a json file is required for external account credentials" unless json_key_io user_creds = read_json_key json_key_io - AwsCredentials.new( + Google::Auth::ExternalAccount::AwsCredentials.new( audience: user_creds["audience"], scope: scope, subject_token_type: user_creds["subject_token_type"], token_url: user_creds["token_url"], credential_source: user_creds["credential_source"], - service_account_impersonation_url: user_creds["service_account_impersonation_url"], - region: ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR] + service_account_impersonation_url: user_creds["service_account_impersonation_url"] ) end @@ -61,388 +58,5 @@ def self.read_json_key json_key_io json_key end end - - # 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 - include BaseClient - - STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze - STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze - IAM_SCOPE = ["https://www.googleapis.com/auth/iam"].freeze - - def initialize options = {} - @audience = options[:audience] - @scope = options[:scope] || IAM_SCOPE - @subject_token_type = options[:subject_token_type] - @token_url = options[:token_url] - @credential_source = options[:credential_source] || {} - @service_account_impersonation_url = options[:service_account_impersonation_url] - - @environment_id = @credential_source["environment_id"] - @region_url = @credential_source["region_url"] - @credential_source_url = @credential_source["url"] - @regional_cred_verification_url = @credential_source["regional_cred_verification_url"] - - @region = options[:region] || region(options) - @request_signer = AwsRequestSigner.new @region - - @expires_at = nil - @access_token = nil - - @sts_client = Google::Auth::OAuth2::STSClient.new token_exchange_endpoint: @token_url - end - - def fetch_access_token! options = {} - credentials = fetch_security_credentials options - - response = exchange_token credentials - - if @service_account_impersonation_url - impersonated_response = get_impersonated_access_token response["access_token"] - self.expires_at = impersonated_response["expireTime"] - self.access_token = impersonated_response["accessToken"] - else - # Extract the expiration time in seconds from the response and calculate the actual expiration time - # and then save that to the expiry variable. - self.expires_at = Time.now.utc + response["expires_in"].to_i - self.access_token = response["access_token"] - end - - notify_refresh_listeners - end - - def notify_refresh_listeners - listeners = defined?(@refresh_listeners) ? @refresh_listeners : [] - listeners.each do |block| - block.call self - end - end - - def expires_within? seconds - @expires_at && @expires_at - Time.now.utc < seconds - end - - def expires_at - @expires_at - end - - def expires_at= new_expires_at - @expires_at = normalize_timestamp new_expires_at - end - - def access_token - @access_token - end - - def access_token= new_access_token - @access_token = new_access_token - end - - private - - def token_type - :access_token - end - - def normalize_timestamp time - case time - when NilClass - nil - when Time - time - when String - Time.parse time - else - raise "Invalid time value #{time}" - end - end - - def exchange_token credentials - request_options = @request_signer.generate_signed_request( - credentials, - @regional_cred_verification_url.sub("{region}", @region), - "POST" - ) - - 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 - - @sts_client.exchange_token( - audience: @audience, - grant_type: STS_GRANT_TYPE, - subject_token: uri_escape(aws_signed_request.to_json), - subject_token_type: @subject_token_type, - scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope, - requested_token_type: STS_REQUESTED_TOKEN_TYPE - ) - end - - def get_impersonated_access_token token, options = {} - c = options[:connection] || Faraday.default_connection - - response = c.post @service_account_impersonation_url do |req| - req.headers["Authorization"] = "Bearer #{token}" - req.headers["Content-Type"] = "application/json" - req.body = MultiJson.dump({ scope: @scope }) - end - - if response.status != 200 - raise "Service account impersonation failed with status #{response.status}" - end - - MultiJson.load response.body - 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 options = {} - 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 options - credentials = fetch_metadata_security_credentials role_name, options - - { - 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. - def fetch_metadata_role_name options = {} - unless @credential_source_url - raise "Unable to determine the AWS metadata server security credentials endpoint" - end - - c = options[:connection] || Faraday.default_connection - response = c.get @credential_source_url - - unless response.success? - raise "Unable to determine the AWS role attached to the current AWS workload" - end - - response.body - end - - # Retrieves the AWS security credentials required for signing AWS - # requests from the AWS metadata server. - def fetch_metadata_security_credentials role_name, options = {} - c = options[:connection] || Faraday.default_connection - - response = c.get "#{@credential_source_url}/#{role_name}", {}, { "Content-Type": "application/json" } - - unless response.success? - raise "Unable to fetch the AWS security credentials required for signing AWS requests" - end - - MultiJson.load response.body - end - - # Region may already be set, if it is then it can just be returned - def region options = {} - unless @region - raise "region_url or region must be set for external account credentials" unless @region_url - - c = options[:connection] || Faraday.default_connection - @region ||= c.get(@region_url).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. - # Args: - # region_name (str): The AWS region to use. - def initialize region_name - @region_name = region_name - end - - # Generates the signed request for the provided HTTP request for calling - # an AWS API. This follows the steps described at: - # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html - # Args: - # aws_security_credentials (Hash[str, str]): A dictionary containing - # the AWS security credentials. - # url (str): The AWS service URL containing the canonical URI and - # query string. - # method (str): The HTTP method used to call this API. - # Returns: - # Hash[str, str]: The AWS signed request dictionary object. - def generate_signed_request aws_credentials, url, method, request_payload = "" - headers = {} - - uri = URI.parse url - - if !uri.hostname || uri.scheme != "https" - raise "Invalid AWS service URL" - end - - service_name = uri.host.split(".").first - - datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ" - date = datetime[0, 8] - - headers["host"] = uri.host - headers["x-amz-date"] = datetime - headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token] - - content_sha256 = sha256_hexdigest request_payload - - canonical_req = canonical_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: method - } - end - - private - - 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.match(/=/) ? p : "#{p}=" } - - params.each.with_index.sort do |a, b| - a, a_offset = a - b, b_offset = b - 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 diff --git a/lib/googleauth/external_account/aws_credentials.rb b/lib/googleauth/external_account/aws_credentials.rb new file mode 100644 index 00000000..144198b1 --- /dev/null +++ b/lib/googleauth/external_account/aws_credentials.rb @@ -0,0 +1,401 @@ +# Copyright 2015 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/base_client" +require "googleauth/helpers/connection" +require "googleauth/oauth2/sts_client" + +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 + include BaseClient + include Helpers::Connection + extend CredentialsLoader + + STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze + STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze + IAM_SCOPE = ["https://www.googleapis.com/auth/iam"].freeze + + def initialize options = {} + @audience = options[:audience] + @scope = options[:scope] || IAM_SCOPE + @subject_token_type = options[:subject_token_type] + @token_url = options[:token_url] + @credential_source = options[:credential_source] || {} + @service_account_impersonation_url = options[:service_account_impersonation_url] + @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"] + + @region = region options + @request_signer = AwsRequestSigner.new @region + + @expires_at = nil + @access_token = nil + + @sts_client = Google::Auth::OAuth2::STSClient.new token_exchange_endpoint: @token_url + end + + def fetch_access_token! options = {} + credentials = fetch_security_credentials options + + response = exchange_token credentials + + if @service_account_impersonation_url + impersonated_response = get_impersonated_access_token response["access_token"] + self.expires_at = impersonated_response["expireTime"] + self.access_token = impersonated_response["accessToken"] + else + # Extract the expiration time in seconds from the response and calculate the actual expiration time + # and then save that to the expiry variable. + self.expires_at = Time.now.utc + response["expires_in"].to_i + self.access_token = response["access_token"] + end + + notify_refresh_listeners + end + + def expires_within? seconds + @expires_at && @expires_at - Time.now.utc < seconds + end + + def expires_at + @expires_at + end + + def expires_at= new_expires_at + @expires_at = normalize_timestamp new_expires_at + end + + def access_token + @access_token + end + + def access_token= new_access_token + @access_token = new_access_token + end + + private + + def token_type + :access_token + end + + def normalize_timestamp time + case time + when NilClass + nil + when Time + time + when String + Time.parse time + else + raise "Invalid time value #{time}" + end + end + + def exchange_token credentials + request_options = @request_signer.generate_signed_request( + credentials, + @regional_cred_verification_url.sub("{region}", @region), + "POST" + ) + + 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 + + @sts_client.exchange_token( + audience: @audience, + grant_type: STS_GRANT_TYPE, + subject_token: uri_escape(aws_signed_request.to_json), + subject_token_type: @subject_token_type, + scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope, + requested_token_type: STS_REQUESTED_TOKEN_TYPE + ) + end + + def get_impersonated_access_token token, options = {} + response = connection(options).post @service_account_impersonation_url do |req| + req.headers["Authorization"] = "Bearer #{token}" + req.headers["Content-Type"] = "application/json" + req.body = MultiJson.dump({ scope: @scope }) + end + + if response.status != 200 + raise "Service account impersonation failed with status #{response.status}" + end + + MultiJson.load response.body + 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 options = {} + 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 options + credentials = fetch_metadata_security_credentials role_name, options + + { + 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. + def fetch_metadata_role_name options = {} + unless @credential_verification_url + raise "Unable to determine the AWS metadata server security credentials endpoint" + end + + response = connection(options).get @credential_verification_url + + unless response.success? + raise "Unable to determine the AWS role attached to the current AWS workload" + end + + response.body + end + + # Retrieves the AWS security credentials required for signing AWS + # requests from the AWS metadata server. + def fetch_metadata_security_credentials role_name, options = {} + response = connection(options).get "#{@credential_verification_url}/#{role_name}", {}, + { "Content-Type": "application/json" } + + unless response.success? + raise "Unable to fetch the AWS security credentials required for signing AWS requests" + end + + MultiJson.load response.body + end + + def region options = {} + @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR] + + unless @region + raise "region_url or region must be set for external account credentials" unless @region_url + + @region ||= connection(options).get(@region_url).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. + # Args: + # region_name (str): The AWS region to use. + def initialize region_name + @region_name = region_name + end + + # Generates the signed request for the provided HTTP request for calling + # an AWS API. This follows the steps described at: + # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html + # Args: + # aws_security_credentials (Hash[str, str]): A dictionary containing + # the AWS security credentials. + # url (str): The AWS service URL containing the canonical URI and + # query string. + # method (str): The HTTP method used to call this API. + # Returns: + # Hash[str, str]: The AWS signed request dictionary object. + def generate_signed_request aws_credentials, url, method, request_payload = "" + headers = {} + + uri = URI.parse url + + if !uri.hostname || uri.scheme != "https" + raise "Invalid AWS service URL" + end + + service_name = uri.host.split(".").first + + datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ" + date = datetime[0, 8] + + headers["host"] = uri.host + headers["x-amz-date"] = datetime + headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token] + + content_sha256 = sha256_hexdigest request_payload + + canonical_req = canonical_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: method + } + end + + private + + 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.match(/=/) ? p : "#{p}=" } + + params.each.with_index.sort do |a, b| + a, a_offset = a + b, b_offset = b + 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 diff --git a/lib/googleauth/helpers/connection.rb b/lib/googleauth/helpers/connection.rb new file mode 100644 index 00000000..be4a4bae --- /dev/null +++ b/lib/googleauth/helpers/connection.rb @@ -0,0 +1,33 @@ +# Copyright 2022 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 "faraday" + +module Google + # Module Auth provides classes that provide Google-specific authorization + # used to access Google APIs. + module Auth + # Helpers provides utility methods for Google::Auth. + module Helpers + # Connection provides a Faraday connection for use with Google::Auth. + module Connection + module_function + + def connection options = {} + options[:connection] || Faraday.default_connection + end + end + end + end +end diff --git a/lib/googleauth/oauth2/sts_client.rb b/lib/googleauth/oauth2/sts_client.rb index 04dc215c..c680f7e3 100644 --- a/lib/googleauth/oauth2/sts_client.rb +++ b/lib/googleauth/oauth2/sts_client.rb @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +require "googleauth/helpers/connection" + module Google module Auth module OAuth2 @@ -28,6 +30,8 @@ module OAuth2 # .. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693 # .. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1 class STSClient + include Helpers::Connection + URLENCODED_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }.freeze # Create a new instance of the STSClient. @@ -35,6 +39,7 @@ class STSClient # @param [String] token_exchange_endpoint # The token exchange endpoint. def initialize options = {} + raise "Token exchange endpoint can not be nil" if options[:token_exchange_endpoint].nil? @token_exchange_endpoint = options[:token_exchange_endpoint] end @@ -67,8 +72,7 @@ def exchange_token options = {} raise ArgumentError, "Missing required options: #{missing_required_opts.join ', '}" end - c = options[:connection] || Faraday.default_connection - + # TODO: Add the ability to add authentication to the headers headers = URLENCODED_HEADERS.dup.merge(options[:additional_headers] || {}) request_body = { @@ -80,7 +84,7 @@ def exchange_token options = {} subject_token_type: options[:subject_token_type] } - response = c.post @token_exchange_endpoint, URI.encode_www_form(request_body), headers + response = connection(options).post @token_exchange_endpoint, URI.encode_www_form(request_body), headers if response.status != 200 raise "Token exchange failed with status #{response.status}" diff --git a/spec/googleauth/external_account/aws_credentials_spec.rb b/spec/googleauth/external_account/aws_credentials_spec.rb new file mode 100644 index 00000000..a9cdfa61 --- /dev/null +++ b/spec/googleauth/external_account/aws_credentials_spec.rb @@ -0,0 +1,91 @@ +# Copyright 2022 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. + +spec_dir = File.expand_path File.join(File.dirname(__FILE__)) +$LOAD_PATH.unshift spec_dir +$LOAD_PATH.uniq! + +require "googleauth" +require "googleauth/external_account/aws_credentials" +require "spec_helper" + +include Google::Auth::CredentialsLoader + +describe Google::Auth::ExternalAccount::AwsCredentials do + AwsCredentials = Google::Auth::ExternalAccount::AwsCredentials + + after :example do + ENV[AWS_DEFAULT_REGION_VAR] = nil + end + + let(:aws_region) { "us-east-1c" } + + describe "when a region url is provided" do + + let :aws_credential_params do + { + token_url: "https://sts.amazonaws.com", + credential_source: { + "region_url" => "http://169.254.169.254/latest/meta-data/placement/availability-zone" + } + } + end + + let :credentials do + AwsCredentials.new(aws_credential_params) + end + + it "does not raise an error" do + stub_request(:get, aws_credential_params.dig(:credential_source, "region_url")) + .to_return(status: 200, body: aws_region, headers: {"Content-Type" => "text/plain"}) + + expect { credentials }.to_not raise_error + end + end + + describe "when a region is provided as an environment variable" do + let(:aws_region) { "us-east-1c" } + + let :aws_credential_params do + { + token_url: "https://sts.amazonaws.com", + } + end + + let :credentials do + AwsCredentials.new(aws_credential_params) + end + + it "does not raise an error" do + ENV[AWS_DEFAULT_REGION_VAR] = aws_region + expect { credentials }.to_not raise_error + end + end + + describe "when a region is not provided" do + let :aws_credential_params do + { + token_url: "https://sts.amazonaws.com", + } + end + + let :credentials do + AwsCredentials.new(aws_credential_params) + end + + it "raises an error" do + expect { credentials }.to raise_error(/region_url or region must be set for external account credentials/) + end + end +end diff --git a/spec/googleauth/external_account_spec.rb b/spec/googleauth/external_account_spec.rb index 160af812..2787fb71 100644 --- a/spec/googleauth/external_account_spec.rb +++ b/spec/googleauth/external_account_spec.rb @@ -1,4 +1,4 @@ -# Copyright 2015 Google, Inc. +# Copyright 2022 Google, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/spec/googleauth/oauth2/sts_client_spec.rb b/spec/googleauth/oauth2/sts_client_spec.rb index 1efe1bb0..ca8a33a0 100644 --- a/spec/googleauth/oauth2/sts_client_spec.rb +++ b/spec/googleauth/oauth2/sts_client_spec.rb @@ -41,37 +41,46 @@ "error_uri": "https://tools.ietf.org/html/rfc6749", }.freeze - let :sts_client do - Google::Auth::OAuth2::STSClient.new({token_exchange_endpoint: TOKEN_EXCHANGE_ENDPOINT}) - end - - - it 'should successfully exchange a token with only required parameters' do - stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 200, body: SUCCESS_RESPONSE.to_json) - - res = sts_client.exchange_token({ - grant_type: GRANT_TYPE, - subject_token: SUBJECT_TOKEN, - subject_token_type: SUBJECT_TOKEN_TYPE, - audience: AUDIENCE, - requested_token_type: REQUESTED_TOKEN_TYPE - }) - - expect(res["access_token"]).to eq(SUCCESS_RESPONSE[:access_token]) - end + context "with valid parameters" do + let :sts_client do + Google::Auth::OAuth2::STSClient.new({token_exchange_endpoint: TOKEN_EXCHANGE_ENDPOINT}) + end - it 'should appropriately handle an error response' do - stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 400, body: ERROR_RESPONSE.to_json) + it 'should successfully exchange a token with only required parameters' do + stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 200, body: SUCCESS_RESPONSE.to_json) - # Expect an exception to be raised - expect { - sts_client.exchange_token({ + res = sts_client.exchange_token({ grant_type: GRANT_TYPE, subject_token: SUBJECT_TOKEN, subject_token_type: SUBJECT_TOKEN_TYPE, audience: AUDIENCE, requested_token_type: REQUESTED_TOKEN_TYPE }) - }.to raise_error(/Token exchange failed with status 400/) + + expect(res["access_token"]).to eq(SUCCESS_RESPONSE[:access_token]) + end + + it 'should appropriately handle an error response' do + stub_request(:post, TOKEN_EXCHANGE_ENDPOINT).to_return(status: 400, body: ERROR_RESPONSE.to_json) + + # Expect an exception to be raised + expect { + sts_client.exchange_token({ + grant_type: GRANT_TYPE, + subject_token: SUBJECT_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE, + audience: AUDIENCE, + requested_token_type: REQUESTED_TOKEN_TYPE + }) + }.to raise_error(/Token exchange failed with status 400/) + end + end + + context "with invalid parameters" do + it 'should raise an error if the token exchange endpoint is not provided' do + expect { + Google::Auth::OAuth2::STSClient.new + }.to raise_error(/Token exchange endpoint can not be nil/) + end end end From a89e88011bfadf403f73eba6ef1945e6d50b2e7a Mon Sep 17 00:00:00 2001 From: Robert Clark Date: Fri, 28 Oct 2022 16:41:40 -0400 Subject: [PATCH 4/5] chore: Update BaseClient documentation comment --- lib/googleauth/base_client.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/googleauth/base_client.rb b/lib/googleauth/base_client.rb index 8ecef6cb..a4d9887b 100644 --- a/lib/googleauth/base_client.rb +++ b/lib/googleauth/base_client.rb @@ -20,7 +20,9 @@ module Google module Auth # BaseClient is a class used to contain common methods that are required by any # Credentials Client, including AwsCredentials, ServiceAccountCredentials, - # and UserRefreshCredentials. + # and UserRefreshCredentials. This is a superclass of Signet::OAuth2::Client + # and has been created to create a generic interface for all credentials clients + # to use, including ones which do not inherit from Signet::OAuth2::Client. module BaseClient AUTH_METADATA_KEY = :authorization From 94e1935f06be6d9c4cc9397cdbbd78dff4f30187 Mon Sep 17 00:00:00 2001 From: Robert Clark Date: Fri, 28 Oct 2022 17:13:19 -0400 Subject: [PATCH 5/5] chore: Ensure the subject token type matches AWS before building AwsCredentials --- lib/googleauth/external_account.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/googleauth/external_account.rb b/lib/googleauth/external_account.rb index 45f72d0c..c82b51ad 100644 --- a/lib/googleauth/external_account.rb +++ b/lib/googleauth/external_account.rb @@ -23,6 +23,10 @@ module Auth # Authenticates requests using External Account credentials, such # as those provided by the AWS provider. class ExternalAccountCredentials + # The subject token type used for AWS external_account credentials. + AWS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:aws:token-type:aws4_request".freeze + AWS_SUBJECT_TOKEN_INVALID = "aws is the only currently supported external account type".freeze + attr_reader :project_id attr_reader :quota_project_id @@ -36,6 +40,7 @@ def self.make_creds options = {} raise "a json file is required for external account credentials" unless json_key_io user_creds = read_json_key json_key_io + raise AWS_SUBJECT_TOKEN_INVALID unless user_creds["subject_token_type"] == AWS_SUBJECT_TOKEN_TYPE Google::Auth::ExternalAccount::AwsCredentials.new( audience: user_creds["audience"], scope: scope,