diff --git a/lib/googleauth/base_client.rb b/lib/googleauth/base_client.rb new file mode 100644 index 00000000..a4d9887b --- /dev/null +++ b/lib/googleauth/base_client.rb @@ -0,0 +1,78 @@ +# 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 "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. 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 + + # 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/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..c82b51ad --- /dev/null +++ b/lib/googleauth/external_account.rb @@ -0,0 +1,67 @@ +# 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/credentials_loader" +require "googleauth/external_account/aws_credentials" + +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 + # 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 + + # 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 + + 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, + 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"] + ) + 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" + ] + wanted.each do |key| + raise "the json is missing the #{key} field" unless json_key.key? key + end + json_key + 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 new file mode 100644 index 00000000..c680f7e3 --- /dev/null +++ b/lib/googleauth/oauth2/sts_client.rb @@ -0,0 +1,98 @@ +# 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. + +require "googleauth/helpers/connection" + +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 + include Helpers::Connection + + 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 = {} + raise "Token exchange endpoint can not be nil" if options[:token_exchange_endpoint].nil? + @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 + + # TODO: Add the ability to add authentication to the headers + headers = URLENCODED_HEADERS.dup.merge(options[:additional_headers] || {}) + + request_body = { + grant_type: options[:grant_type], + audience: options[:audience], + scope: Array(options[:scopes])&.join(" ") || [], + requested_token_type: options[:requested_token_type], + subject_token: options[:subject_token], + subject_token_type: options[:subject_token_type] + } + + 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}" + end + + MultiJson.load response.body + end + end + end + end +end 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/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 new file mode 100644 index 00000000..2787fb71 --- /dev/null +++ b/spec/googleauth/external_account_spec.rb @@ -0,0 +1,159 @@ +# 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 "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 diff --git a/spec/googleauth/oauth2/sts_client_spec.rb b/spec/googleauth/oauth2/sts_client_spec.rb new file mode 100644 index 00000000..ca8a33a0 --- /dev/null +++ b/spec/googleauth/oauth2/sts_client_spec.rb @@ -0,0 +1,86 @@ +# 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 + + context "with valid parameters" do + 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 + + 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