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