diff --git a/.rubocop.yml b/.rubocop.yml index 0e9126a..5f3d507 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,6 +11,8 @@ Metrics/CyclomaticComplexity: Max: 8 Metrics/MethodLength: Max: 20 +Metrics/ModuleLength: + Max: 150 Metrics/ClassLength: Enabled: false Layout/IndentHeredoc: diff --git a/lib/googleauth/application_default.rb b/lib/googleauth/application_default.rb index 82a8895..bdd895d 100644 --- a/lib/googleauth/application_default.rb +++ b/lib/googleauth/application_default.rb @@ -52,11 +52,21 @@ ERROR_MESSAGE # scope is ignored. # # @param scope [string|array|nil] the scope(s) to access - # @param options [hash] allows override of the connection being used + # @param options [Hash] Connection options. These may be used to configure + # the `Faraday::Connection` used for outgoing HTTP requests. For + # example, if a connection proxy must be used in the current network, + # you may provide a connection with with the needed proxy options. + # The following keys are recognized: + # * `:default_connection` The connection object to use for token + # refresh requests. + # * `:connection_builder` A `Proc` that creates and returns a + # connection to use for token refresh requests. + # * `:connection` The connection to use to determine whether GCE + # metadata credentials are available. def get_application_default(scope = nil, options = {}) - creds = DefaultCredentials.from_env(scope) || - DefaultCredentials.from_well_known_path(scope) || - DefaultCredentials.from_system_default_path(scope) + creds = DefaultCredentials.from_env(scope, options) || + DefaultCredentials.from_well_known_path(scope, options) || + DefaultCredentials.from_system_default_path(scope, options) return creds unless creds.nil? unless GCECredentials.on_gce?(options) # Clear cache of the result of GCECredentials.on_gce? diff --git a/lib/googleauth/compute_engine.rb b/lib/googleauth/compute_engine.rb index 243e040..ed80fa4 100644 --- a/lib/googleauth/compute_engine.rb +++ b/lib/googleauth/compute_engine.rb @@ -87,10 +87,9 @@ ERROR # fetched. def fetch_access_token(options = {}) c = options[:connection] || Faraday.default_connection - c.headers = { 'Metadata-Flavor' => 'Google' } - retry_with_error do - resp = c.get(COMPUTE_AUTH_TOKEN_URI) + headers = { 'Metadata-Flavor' => 'Google' } + resp = c.get(COMPUTE_AUTH_TOKEN_URI, nil, headers) case resp.status when 200 Signet::OAuth2.parse_credentials(resp.body, diff --git a/lib/googleauth/credentials.rb b/lib/googleauth/credentials.rb index 98197c7..a6978f4 100644 --- a/lib/googleauth/credentials.rb +++ b/lib/googleauth/credentials.rb @@ -66,14 +66,14 @@ module Google elsif keyfile.is_a? Hash hash = stringify_hash_keys keyfile hash['scope'] ||= scope - @client = init_client hash + @client = init_client hash, options @project_id ||= (hash['project_id'] || hash['project']) else verify_keyfile_exists! keyfile json = JSON.parse ::File.read(keyfile) json['scope'] ||= scope @project_id ||= (json['project_id'] || json['project']) - @client = init_client json + @client = init_client json, options end CredentialsLoader.warn_if_cloud_sdk_credentials @client.client_id @project_id ||= CredentialsLoader.load_gcloud_project_id @@ -85,33 +85,32 @@ module Google # previously stated locations do not contain keyfile information, # this method defaults to use the application default. def self.default(options = {}) - scope = options[:scope] # First try to find keyfile file from environment variables. - client = from_path_vars scope + client = from_path_vars options # Second try to find keyfile json from environment variables. - client ||= from_json_vars scope + client ||= from_json_vars options # Third try to find keyfile file from known file paths. - client ||= from_default_paths scope + client ||= from_default_paths options # Finally get instantiated client from Google::Auth - client ||= from_application_default scope + client ||= from_application_default options client end - def self.from_path_vars(scope) + def self.from_path_vars(options) self::PATH_ENV_VARS .map { |v| ENV[v] } .compact .select { |p| ::File.file? p } .each do |file| - return new file, scope: scope + return new file, options end nil end - def self.from_json_vars(scope) + def self.from_json_vars(options) json = lambda do |v| unless ENV[v].nil? begin @@ -122,24 +121,24 @@ module Google end end self::JSON_ENV_VARS.map(&json).compact.each do |hash| - return new hash, scope: scope + return new hash, options end nil end - def self.from_default_paths(scope) + def self.from_default_paths(options) self::DEFAULT_PATHS .select { |p| ::File.file? p } .each do |file| - return new file, scope: scope + return new file, options end nil end - def self.from_application_default(scope) - scope ||= self::SCOPE + def self.from_application_default(options) + scope = options[:scope] || self::SCOPE client = Google::Auth.get_application_default scope - new client + new client, options end private_class_method :from_path_vars, :from_json_vars, @@ -161,9 +160,10 @@ module Google end # Initializes the Signet client. - def init_client(keyfile) + def init_client(keyfile, connection_options = {}) client_opts = client_options keyfile - Signet::OAuth2::Client.new client_opts + Signet::OAuth2::Client.new(client_opts) + .configure_connection(connection_options) end # returns a new Hash with string keys instead of symbol keys. diff --git a/lib/googleauth/credentials_loader.rb b/lib/googleauth/credentials_loader.rb index 6af33e4..265fda2 100644 --- a/lib/googleauth/credentials_loader.rb +++ b/lib/googleauth/credentials_loader.rb @@ -76,22 +76,35 @@ module Google # By default, it calls #new on the current class, but this behaviour can # be modified, allowing different instances to be created. def make_creds(*args) - new(*args) + creds = new(*args) + if creds.respond_to?(:configure_connection) && args.size == 1 + creds = creds.configure_connection(args[0]) + end + creds end # Creates an instance from the path specified in an environment # variable. # # @param scope [string|array|nil] the scope(s) to access - def from_env(scope = nil) + # @param options [Hash] Connection options. These may be used to configure + # how OAuth tokens are retrieved, by providing a suitable + # `Faraday::Connection`. For example, if a connection proxy must be + # used in the current network, you may provide a connection with + # with the needed proxy options. + # The following keys are recognized: + # * `:default_connection` The connection object to use. + # * `:connection_builder` A `Proc` that returns a connection. + def from_env(scope = nil, options = {}) + options = interpret_options scope, options if ENV.key?(ENV_VAR) path = ENV[ENV_VAR] raise "file #{path} does not exist" unless File.exist?(path) File.open(path) do |f| - return make_creds(json_key_io: f, scope: scope) + return make_creds(options.merge(json_key_io: f)) end elsif service_account_env_vars? || authorized_user_env_vars? - return make_creds(scope: scope) + return make_creds(options) end rescue StandardError => e raise "#{NOT_FOUND_ERROR}: #{e}" @@ -100,7 +113,16 @@ module Google # Creates an instance from a well known path. # # @param scope [string|array|nil] the scope(s) to access - def from_well_known_path(scope = nil) + # @param options [Hash] Connection options. These may be used to configure + # how OAuth tokens are retrieved, by providing a suitable + # `Faraday::Connection`. For example, if a connection proxy must be + # used in the current network, you may provide a connection with + # with the needed proxy options. + # The following keys are recognized: + # * `:default_connection` The connection object to use. + # * `:connection_builder` A `Proc` that returns a connection. + def from_well_known_path(scope = nil, options = {}) + options = interpret_options scope, options home_var = OS.windows? ? 'APPDATA' : 'HOME' base = WELL_KNOWN_PATH root = ENV[home_var].nil? ? '' : ENV[home_var] @@ -108,7 +130,7 @@ module Google path = File.join(root, base) return nil unless File.exist?(path) File.open(path) do |f| - return make_creds(json_key_io: f, scope: scope) + return make_creds(options.merge(json_key_io: f)) end rescue StandardError => e raise "#{WELL_KNOWN_ERROR}: #{e}" @@ -117,7 +139,16 @@ module Google # Creates an instance from the system default path # # @param scope [string|array|nil] the scope(s) to access - def from_system_default_path(scope = nil) + # @param options [Hash] Connection options. These may be used to configure + # how OAuth tokens are retrieved, by providing a suitable + # `Faraday::Connection`. For example, if a connection proxy must be + # used in the current network, you may provide a connection with + # with the needed proxy options. + # The following keys are recognized: + # * `:default_connection` The connection object to use. + # * `:connection_builder` A `Proc` that returns a connection. + def from_system_default_path(scope = nil, options = {}) + options = interpret_options scope, options if OS.windows? return nil unless ENV['ProgramData'] prefix = File.join(ENV['ProgramData'], 'Google/Auth') @@ -127,7 +158,7 @@ module Google path = File.join(prefix, CREDENTIALS_FILE_NAME) return nil unless File.exist?(path) File.open(path) do |f| - return make_creds(json_key_io: f, scope: scope) + return make_creds(options.merge(json_key_io: f)) end rescue StandardError => e raise "#{SYSTEM_DEFAULT_ERROR}: #{e}" @@ -152,6 +183,18 @@ module Google private + def interpret_options(scope, options) + if scope.is_a? Hash + options = scope + scope = nil + end + if scope && !options[:scope] + options.merge(scope: scope) + else + options + end + end + def service_account_env_vars? ([PRIVATE_KEY_VAR, CLIENT_EMAIL_VAR] - ENV.keys).empty? end diff --git a/lib/googleauth/default_credentials.rb b/lib/googleauth/default_credentials.rb index 68b3e78..37e9836 100644 --- a/lib/googleauth/default_credentials.rb +++ b/lib/googleauth/default_credentials.rb @@ -46,16 +46,16 @@ module Google # override CredentialsLoader#make_creds to use the class determined by # loading the json. def self.make_creds(options = {}) - json_key_io, scope = options.values_at(:json_key_io, :scope) + json_key_io = options[:json_key_io] if json_key_io json_key, clz = determine_creds_class(json_key_io) warn_if_cloud_sdk_credentials json_key['client_id'] - clz.make_creds(json_key_io: StringIO.new(MultiJson.dump(json_key)), - scope: scope) + io = StringIO.new(MultiJson.dump(json_key)) + clz.make_creds(options.merge(json_key_io: io)) else warn_if_cloud_sdk_credentials ENV[CredentialsLoader::CLIENT_ID_VAR] clz = read_creds - clz.make_creds(scope: scope) + clz.make_creds(options) end end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index cc23f72..764f931 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -73,6 +73,7 @@ module Google issuer: client_email, signing_key: OpenSSL::PKey::RSA.new(private_key), project_id: project_id) + .configure_connection(options) end # Handles certain escape sequences that sometimes appear in input. diff --git a/lib/googleauth/signet.rb b/lib/googleauth/signet.rb index 02927aa..54993d3 100644 --- a/lib/googleauth/signet.rb +++ b/lib/googleauth/signet.rb @@ -38,6 +38,12 @@ module Signet # This reopens Client to add #apply and #apply! methods which update a # hash with the fetched authentication token. class Client + def configure_connection(options) + @connection_info = + options[:connection_builder] || options[:default_connection] + self + 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 @@ -66,6 +72,10 @@ module Signet alias orig_fetch_access_token! fetch_access_token! def fetch_access_token!(options = {}) + unless options[:connection] + connection = build_default_connection + options = options.merge(connection: connection) if connection + end info = orig_fetch_access_token!(options) notify_refresh_listeners info @@ -78,6 +88,16 @@ module Signet end end + def build_default_connection + if !defined?(@connection_info) + nil + elsif @connection_info.respond_to? :call + @connection_info.call + else + @connection_info + end + end + def retry_with_error(max_retry_count = 5) retry_count = 0 diff --git a/lib/googleauth/user_refresh.rb b/lib/googleauth/user_refresh.rb index 211cb31..782ec5d 100644 --- a/lib/googleauth/user_refresh.rb +++ b/lib/googleauth/user_refresh.rb @@ -72,6 +72,7 @@ module Google refresh_token: user_creds['refresh_token'], project_id: user_creds['project_id'], scope: scope) + .configure_connection(options) end # Reads the client_id, client_secret and refresh_token fields from the diff --git a/spec/googleauth/credentials_spec.rb b/spec/googleauth/credentials_spec.rb index 759b8b5..24191b3 100644 --- a/spec/googleauth/credentials_spec.rb +++ b/spec/googleauth/credentials_spec.rb @@ -47,6 +47,7 @@ describe Google::Auth::Credentials, :private do it 'uses a default scope' do mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -64,6 +65,7 @@ describe Google::Auth::Credentials, :private do it 'uses a custom scope' do mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -96,6 +98,7 @@ describe Google::Auth::Credentials, :private do allow(::File).to receive(:file?).with(TEST_PATH_ENV_VAL) { false } mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -129,6 +132,7 @@ describe Google::Auth::Credentials, :private do allow(::File).to receive(:read).with('/unknown/path/to/file.txt') { JSON.generate(default_keyfile_hash) } mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -161,6 +165,7 @@ describe Google::Auth::Credentials, :private do allow(::ENV).to receive(:[]).with('JSON_ENV_TEST') { JSON.generate(default_keyfile_hash) } mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -194,6 +199,7 @@ describe Google::Auth::Credentials, :private do allow(::File).to receive(:read).with('~/default/path/to/file.txt') { JSON.generate(default_keyfile_hash) } mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Signet::OAuth2::Client).to receive(:new) do |options| @@ -226,6 +232,7 @@ describe Google::Auth::Credentials, :private do allow(::File).to receive(:file?).with('~/default/path/to/file.txt') { false } mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(mocked_signet).to receive(:client_id) allow(Google::Auth).to receive(:get_application_default) do |scope| @@ -253,6 +260,7 @@ describe Google::Auth::Credentials, :private do it 'warns when cloud sdk credentials are used' do mocked_signet = double('Signet::OAuth2::Client') + allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet) allow(mocked_signet).to receive(:fetch_access_token!).and_return(true) allow(Signet::OAuth2::Client).to receive(:new) do |options| mocked_signet diff --git a/spec/googleauth/get_application_default_spec.rb b/spec/googleauth/get_application_default_spec.rb index 9ec1dfa..811a9a6 100644 --- a/spec/googleauth/get_application_default_spec.rb +++ b/spec/googleauth/get_application_default_spec.rb @@ -100,6 +100,19 @@ describe '#get_application_default' do end end + it "propagates default_connection option" do + Dir.mktmpdir do |dir| + key_path = File.join(dir, 'my_cert_file') + FileUtils.mkdir_p(File.dirname(key_path)) + File.write(key_path, cred_json_text) + ENV[@var_name] = key_path + connection = Faraday.new(headers: {"User-Agent" => "hello"}) + opts = options.merge(default_connection: connection) + creds = Google::Auth.get_application_default(@scope, opts) + expect(creds.build_default_connection).to be connection + end + end + it 'succeeds with default file without GOOGLE_APPLICATION_CREDENTIALS' do ENV.delete(@var_name) unless ENV[@var_name].nil? Dir.mktmpdir do |dir| diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 1652a2b..76a4cfc 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -229,6 +229,14 @@ describe Google::Auth::ServiceAccountCredentials do ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] expect(@clz.from_env(@scope)).to_not be_nil end + + it "propagates default_connection option" do + ENV[PRIVATE_KEY_VAR] = cred_json[:private_key] + ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] + connection = Faraday.new(headers: {"User-Agent" => "hello"}) + creds = @clz.from_env(@scope, default_connection: connection) + expect(creds.build_default_connection).to be connection + end end describe '#from_well_known_path' do @@ -274,6 +282,20 @@ describe Google::Auth::ServiceAccountCredentials do expect(credentials.project_id).to eq(cred_json[:project_id]) end end + + it "propagates default_connection option" do + Dir.mktmpdir do |dir| + key_path = File.join(dir, '.config', @known_path) + key_path = File.join(dir, WELL_KNOWN_PATH) if OS.windows? + FileUtils.mkdir_p(File.dirname(key_path)) + File.write(key_path, cred_json_text) + ENV['HOME'] = dir + ENV['APPDATA'] = dir + connection = Faraday.new(headers: {"User-Agent" => "hello"}) + creds = @clz.from_well_known_path(@scope, default_connection: connection) + expect(creds.build_default_connection).to be connection + end + end end describe '#from_system_default_path' do @@ -305,6 +327,18 @@ describe Google::Auth::ServiceAccountCredentials do File.delete(@path) end end + + it "propagates default_connection option" do + FakeFS do + ENV['ProgramData'] = '/etc' + FileUtils.mkdir_p(File.dirname(@path)) + File.write(@path, cred_json_text) + connection = Faraday.new(headers: {"User-Agent" => "hello"}) + creds = @clz.from_system_default_path(@scope, default_connection: connection) + expect(creds.build_default_connection).to be connection + File.delete(@path) + end + end end end diff --git a/spec/googleauth/signet_spec.rb b/spec/googleauth/signet_spec.rb index 2d00896..0f52768 100644 --- a/spec/googleauth/signet_spec.rb +++ b/spec/googleauth/signet_spec.rb @@ -60,14 +60,45 @@ describe Signet::OAuth2::Client do @key.public_key, true, algorithm: 'RS256') end + with_params = {body: hash_including( + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer")} + if opts[:user_agent] + with_params[:headers] = {"User-Agent" => opts[:user_agent]} + end stub_request(:post, 'https://oauth2.googleapis.com/token') - .with(body: hash_including( - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer' - ), &blk) + .with(with_params, &blk) .to_return(body: body, status: 200, headers: { 'Content-Type' => 'application/json' }) end it_behaves_like 'apply/apply! are OK' + + describe "#configure_connection" do + it "honors default_connection" do + token = "1/abcdef1234567890" + stub = make_auth_stubs access_token: token, user_agent: "RubyRocks/1.0" + conn = Faraday.new headers: {"User-Agent" => "RubyRocks/1.0"} + @client.configure_connection(default_connection: conn) + md = { foo: "bar" } + @client.apply!(md) + want = { foo: "bar", authorization: "Bearer #{token}" } + expect(md).to eq(want) + expect(stub).to have_been_requested + end + + it "honors connection_builder" do + token = "1/abcdef1234567890" + stub = make_auth_stubs access_token: token, user_agent: "RubyRocks/2.0" + connection_builder = proc do + Faraday.new headers: {"User-Agent" => "RubyRocks/2.0"} + end + @client.configure_connection(connection_builder: connection_builder) + md = { foo: "bar" } + @client.apply!(md) + want = { foo: "bar", authorization: "Bearer #{token}" } + expect(md).to eq(want) + expect(stub).to have_been_requested + end + end end