From 5061fb5add07035695c742d977808639f7155bef Mon Sep 17 00:00:00 2001 From: Herbert Siojo Date: Thu, 21 May 2015 13:38:19 -0700 Subject: [PATCH] Enables reading credentials from env vars. - ServiceAccountCredentials, ServiceAccountJwtHeaderCredentials and UserRefreshCredentials initializers now take keyword args via options hash. - In `credentials_loader.rb`, refactored env var checking into private methods - Updated tests & added new tests. - Fixed existing test for #from_well_known_path 'fails if the file is invalid', where `from_env` was called instead of `from_well_known_path`. - Fixed rubocop errors I introduced, but two existing ones remain. - Added entry to changelog. - Fixed rubocop errors from code containing parallel assignments - Updated rubocop_todo.yml to ignore parallel assignments and trailing underscore assignments. --- .rubocop_todo.yml | 14 +++- CHANGELOG.md | 6 ++ lib/googleauth.rb | 32 +++++++-- lib/googleauth/credentials_loader.rb | 34 +++++++-- lib/googleauth/service_account.rb | 25 +++++-- lib/googleauth/user_refresh.rb | 11 ++- .../get_application_default_spec.rb | 60 ++++++++++++---- spec/googleauth/service_account_spec.rb | 70 +++++++++++++------ spec/googleauth/user_refresh_spec.rb | 46 ++++++++---- 9 files changed, 227 insertions(+), 71 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index b909cd2..6323724 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,5 +1,5 @@ # This configuration was generated by `rubocop --auto-gen-config` -# on 2015-04-23 11:18:24 -0700 using RuboCop version 0.30.0. +# on 2015-05-18 09:38:28 -0700 using RuboCop version 0.31.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -9,7 +9,17 @@ Metrics/AbcSize: Max: 24 -# Offense count: 6 +# Offense count: 10 # Configuration parameters: CountComments. Metrics/MethodLength: Max: 13 + +# Offense count: 1 +# Cop supports --auto-correct. +Performance/ParallelAssignment: + Enabled: false + +# Offense count: 1 +# Cop supports --auto-correct. +Style/TrailingUnderscoreVariable: + Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 7347d56..85d73dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### Changes + +* Enables passing credentials via environment variables. ([@haabaato][]) +[#27](https://github.com/google/google-auth-library-ruby/issues/27) + ## 0.4.1 (25/04/2015) ### Changes @@ -20,3 +25,4 @@ [@tbetbetbe]: https://github.com/tbetbetbe [@joneslee85]: https://github.com/joneslee85 +[@haabaato]: https://github.com/haabaato diff --git a/lib/googleauth.rb b/lib/googleauth.rb index ca9b750..520bbf4 100644 --- a/lib/googleauth.rb +++ b/lib/googleauth.rb @@ -52,16 +52,38 @@ END # override CredentialsLoader#make_creds to use the class determined by # loading the json. - def self.make_creds(json_key_io, scope = nil) - json_key, clz = determine_creds_class(json_key_io) - clz.new(StringIO.new(MultiJson.dump(json_key)), scope) + def self.make_creds(options = {}) + json_key_io, scope = options.values_at(:json_key_io, :scope) + if json_key_io + json_key, clz = determine_creds_class(json_key_io) + clz.new(json_key_io: StringIO.new(MultiJson.dump(json_key)), + scope: scope) + else + clz = read_creds + clz.new(scope: scope) + end + end + + def self.read_creds + env_var = CredentialsLoader::ACCOUNT_TYPE_VAR + type = ENV[env_var] + fail "#{ACCOUNT_TYPE_VAR} is undefined in env" unless type + case type + when 'service_account' + ServiceAccountCredentials + when 'authorized_user' + UserRefreshCredentials + else + fail "credentials type '#{type}' is not supported" + end end # Reads the input json and determines which creds class to use. def self.determine_creds_class(json_key_io) json_key = MultiJson.load(json_key_io.read) - fail "the json is missing the #{key} field" unless json_key.key?('type') - type = json_key['type'] + key = 'type' + fail "the json is missing the '#{key}' field" unless json_key.key?(key) + type = json_key[key] case type when 'service_account' [json_key, ServiceAccountCredentials] diff --git a/lib/googleauth/credentials_loader.rb b/lib/googleauth/credentials_loader.rb index f5ed2b2..d3cbf0c 100644 --- a/lib/googleauth/credentials_loader.rb +++ b/lib/googleauth/credentials_loader.rb @@ -39,6 +39,14 @@ module Google module CredentialsLoader extend Memoist ENV_VAR = 'GOOGLE_APPLICATION_CREDENTIALS' + + PRIVATE_KEY_VAR = 'GOOGLE_PRIVATE_KEY' + CLIENT_EMAIL_VAR = 'GOOGLE_CLIENT_EMAIL' + CLIENT_ID_VAR = 'GOOGLE_CLIENT_ID' + CLIENT_SECRET_VAR = 'GOOGLE_CLIENT_SECRET' + REFRESH_TOKEN_VAR = 'GOOGLE_REFRESH_TOKEN' + ACCOUNT_TYPE_VAR = 'GOOGLE_ACCOUNT_TYPE' + NOT_FOUND_ERROR = "Unable to read the credential file specified by #{ENV_VAR}" WELL_KNOWN_PATH = 'gcloud/application_default_credentials.json' @@ -63,11 +71,14 @@ module Google # # @param scope [string|array|nil] the scope(s) to access def from_env(scope = nil) - return nil unless ENV.key?(ENV_VAR) - path = ENV[ENV_VAR] - fail 'file #{path} does not exist' unless File.exist?(path) - File.open(path) do |f| - return make_creds(f, scope) + if ENV.key?(ENV_VAR) + path = ENV[ENV_VAR] + fail "file #{path} does not exist" unless File.exist?(path) + File.open(path) do |f| + return make_creds(json_key_io: f, scope: scope) + end + elsif service_account_env_vars? || authorized_user_env_vars? + return make_creds(scope: scope) end rescue StandardError => e raise "#{NOT_FOUND_ERROR}: #{e}" @@ -83,11 +94,22 @@ module Google path = File.join(root, base) return nil unless File.exist?(path) File.open(path) do |f| - return make_creds(f, scope) + return make_creds(json_key_io: f, scope: scope) end rescue StandardError => e raise "#{WELL_KNOWN_ERROR}: #{e}" end + + private + + def service_account_env_vars? + ([PRIVATE_KEY_VAR, CLIENT_EMAIL_VAR] - ENV.keys).empty? + end + + def authorized_user_env_vars? + ([CLIENT_ID_VAR, CLIENT_SECRET_VAR, REFRESH_TOKEN_VAR] - + ENV.keys).empty? + end end end end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index c74d3cc..8e4fba2 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -62,8 +62,15 @@ module Google # # @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 initialize(json_key_io, scope = nil) - private_key, client_email = self.class.read_json_key(json_key_io) + def initialize(options = {}) + json_key_io, scope = options.values_at(:json_key_io, :scope) + if json_key_io + private_key, client_email = self.class.read_json_key(json_key_io) + else + private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR] + client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] + end + super(token_credential_uri: TOKEN_CRED_URI, audience: TOKEN_CRED_URI, scope: scope, @@ -90,7 +97,7 @@ module Google client_email: @issuer } alt_clz = ServiceAccountJwtHeaderCredentials - alt = alt_clz.new(StringIO.new(MultiJson.dump(cred_json))) + alt = alt_clz.new(json_key_io: StringIO.new(MultiJson.dump(cred_json))) alt.apply!(a_hash) end end @@ -120,7 +127,7 @@ module Google # optional scope. Here's the constructor only has one param, so # we modify make_creds to reflect this. def self.make_creds(*args) - new(args[0]) + new(json_key_io: args[0][:json_key_io]) end # Reads the private key and client email fields from the service account @@ -135,8 +142,14 @@ module Google # Initializes a ServiceAccountJwtHeaderCredentials. # # @param json_key_io [IO] an IO from which the JSON key can be read - def initialize(json_key_io) - private_key, client_email = self.class.read_json_key(json_key_io) + def initialize(options = {}) + json_key_io = options[:json_key_io] + if json_key_io + private_key, client_email = self.class.read_json_key(json_key_io) + else + private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR] + client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] + end @private_key = private_key @issuer = client_email @signing_key = OpenSSL::PKey::RSA.new(private_key) diff --git a/lib/googleauth/user_refresh.rb b/lib/googleauth/user_refresh.rb index 4256936..ebb2e24 100644 --- a/lib/googleauth/user_refresh.rb +++ b/lib/googleauth/user_refresh.rb @@ -63,8 +63,15 @@ module Google # # @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 initialize(json_key_io, scope = nil) - user_creds = self.class.read_json_key(json_key_io) + def initialize(options = {}) + json_key_io, scope = options.values_at(:json_key_io, :scope) + user_creds = self.class.read_json_key(json_key_io) if json_key_io + user_creds ||= { + client_id: ENV[CredentialsLoader::CLIENT_ID_VAR], + client_secret: ENV[CredentialsLoader::CLIENT_SECRET_VAR], + refresh_token: ENV[CredentialsLoader::REFRESH_TOKEN_VAR] + } + super(token_credential_uri: TOKEN_CRED_URI, client_id: user_creds['client_id'], client_secret: user_creds['client_secret'], diff --git a/spec/googleauth/get_application_default_spec.rb b/spec/googleauth/get_application_default_spec.rb index 43556ea..e143b0b 100644 --- a/spec/googleauth/get_application_default_spec.rb +++ b/spec/googleauth/get_application_default_spec.rb @@ -38,14 +38,18 @@ require 'spec_helper' describe '#get_application_default' do before(:example) do @key = OpenSSL::PKey::RSA.new(2048) - @var_name = CredentialsLoader::ENV_VAR - @orig = ENV[@var_name] + @var_name = ENV_VAR + @credential_vars = [ + ENV_VAR, PRIVATE_KEY_VAR, CLIENT_EMAIL_VAR, CLIENT_ID_VAR, + CLIENT_SECRET_VAR, REFRESH_TOKEN_VAR, ACCOUNT_TYPE_VAR] + @original_env_vals = {} + @credential_vars.each { |var| @original_env_vals[var] = ENV[var] } @home = ENV['HOME'] @scope = 'https://www.googleapis.com/auth/userinfo.profile' end after(:example) do - ENV[@var_name] = @orig unless @orig.nil? + @credential_vars.each { |var| ENV[var] = @original_env_vals[var] } ENV['HOME'] = @home unless @home == ENV['HOME'] end @@ -95,8 +99,7 @@ describe '#get_application_default' do it 'succeeds with default file without GOOGLE_APPLICATION_CREDENTIALS' do ENV.delete(@var_name) unless ENV[@var_name].nil? Dir.mktmpdir do |dir| - key_path = File.join(dir, '.config', - CredentialsLoader::WELL_KNOWN_PATH) + key_path = File.join(dir, '.config', WELL_KNOWN_PATH) FileUtils.mkdir_p(File.dirname(key_path)) File.write(key_path, cred_json_text) ENV['HOME'] = dir @@ -107,8 +110,7 @@ describe '#get_application_default' do it 'succeeds with default file without a scope' do ENV.delete(@var_name) unless ENV[@var_name].nil? Dir.mktmpdir do |dir| - key_path = File.join(dir, '.config', - CredentialsLoader::WELL_KNOWN_PATH) + key_path = File.join(dir, '.config', WELL_KNOWN_PATH) FileUtils.mkdir_p(File.dirname(key_path)) File.write(key_path, cred_json_text) ENV['HOME'] = dir @@ -137,17 +139,31 @@ describe '#get_application_default' do end stubs.verify_stubbed_calls end + + it 'succeeds if environment vars are valid' do + ENV.delete(@var_name) unless ENV[@var_name].nil? # no env var + ENV[PRIVATE_KEY_VAR] = cred_json[:private_key] + ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] + ENV[CLIENT_ID_VAR] = cred_json[:client_id] + ENV[CLIENT_SECRET_VAR] = cred_json[:client_secret] + ENV[REFRESH_TOKEN_VAR] = cred_json[:refresh_token] + ENV[ACCOUNT_TYPE_VAR] = cred_json[:type] + expect(Google::Auth.get_application_default(@scope)).to_not be_nil + end end describe 'when credential type is service account' do - def cred_json_text - cred_json = { + let(:cred_json) do + { private_key_id: 'a_private_key_id', private_key: @key.to_pem, client_email: 'app@developer.gserviceaccount.com', client_id: 'app.apps.googleusercontent.com', type: 'service_account' } + end + + def cred_json_text MultiJson.dump(cred_json) end @@ -156,13 +172,16 @@ describe '#get_application_default' do end describe 'when credential type is authorized_user' do - def cred_json_text - cred_json = { + let(:cred_json) do + { client_secret: 'privatekey', refresh_token: 'refreshtoken', client_id: 'app.apps.googleusercontent.com', type: 'authorized_user' } + end + + def cred_json_text MultiJson.dump(cred_json) end @@ -171,13 +190,16 @@ describe '#get_application_default' do end describe 'when credential type is unknown' do - def cred_json_text - cred_json = { + let(:cred_json) do + { client_secret: 'privatekey', refresh_token: 'refreshtoken', client_id: 'app.apps.googleusercontent.com', type: 'not_known_type' } + end + + def cred_json_text MultiJson.dump(cred_json) end @@ -197,8 +219,7 @@ describe '#get_application_default' do it 'fails if the well known file contains the creds' do ENV.delete(@var_name) unless ENV[@var_name].nil? Dir.mktmpdir do |dir| - key_path = File.join(dir, '.config', - CredentialsLoader::WELL_KNOWN_PATH) + key_path = File.join(dir, '.config', WELL_KNOWN_PATH) FileUtils.mkdir_p(File.dirname(key_path)) File.write(key_path, cred_json_text) ENV['HOME'] = dir @@ -208,5 +229,14 @@ describe '#get_application_default' do expect(&blk).to raise_error RuntimeError end end + + it 'fails if env vars are set' do + ENV[PRIVATE_KEY_VAR] = cred_json[:private_key] + ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] + blk = proc do + Google::Auth.get_application_default(@scope) + end + expect(&blk).to raise_error RuntimeError + end end end diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 7d5570a..7c3209f 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -108,12 +108,22 @@ end describe Google::Auth::ServiceAccountCredentials do ServiceAccountCredentials = Google::Auth::ServiceAccountCredentials let(:client_email) { 'app@developer.gserviceaccount.com' } + let(:cred_json) do + { + private_key_id: 'a_private_key_id', + private_key: @key.to_pem, + client_email: client_email, + client_id: 'app.apps.googleusercontent.com', + type: 'service_account' + } + end before(:example) do @key = OpenSSL::PKey::RSA.new(2048) @client = ServiceAccountCredentials.new( - StringIO.new(cred_json_text), - 'https://www.googleapis.com/auth/userinfo.profile') + json_key_io: StringIO.new(cred_json_text), + scope: 'https://www.googleapis.com/auth/userinfo.profile' + ) end def make_auth_stubs(opts = {}) @@ -131,13 +141,6 @@ describe Google::Auth::ServiceAccountCredentials do end def cred_json_text - cred_json = { - private_key_id: 'a_private_key_id', - private_key: @key.to_pem, - client_email: client_email, - client_id: 'app.apps.googleusercontent.com', - type: 'service_account' - } MultiJson.dump(cred_json) end @@ -154,13 +157,18 @@ describe Google::Auth::ServiceAccountCredentials do describe '#from_env' do before(:example) do @var_name = ENV_VAR - @orig = ENV[@var_name] + @credential_vars = [ + ENV_VAR, PRIVATE_KEY_VAR, CLIENT_EMAIL_VAR, ACCOUNT_TYPE_VAR] + @original_env_vals = {} + @credential_vars.each { |var| @original_env_vals[var] = ENV[var] } + ENV[ACCOUNT_TYPE_VAR] = cred_json[:type] + @scope = 'https://www.googleapis.com/auth/userinfo.profile' @clz = ServiceAccountCredentials end after(:example) do - ENV[@var_name] = @orig unless @orig.nil? + @credential_vars.each { |var| ENV[var] = @original_env_vals[var] } end it 'returns nil if the GOOGLE_APPLICATION_CREDENTIALS is unset' do @@ -187,6 +195,13 @@ describe Google::Auth::ServiceAccountCredentials do expect(@clz.from_env(@scope)).to_not be_nil end end + + it 'succeeds when GOOGLE_PRIVATE_KEY and GOOGLE_CLIENT_EMAIL env vars are'\ + ' valid' do + ENV[PRIVATE_KEY_VAR] = cred_json[:private_key] + ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] + expect(@clz.from_env(@scope)).to_not be_nil + end end describe '#from_well_known_path' do @@ -224,20 +239,22 @@ describe Google::Auth::ServiceAccountJwtHeaderCredentials do let(:client_email) { 'app@developer.gserviceaccount.com' } let(:clz) { Google::Auth::ServiceAccountJwtHeaderCredentials } - - before(:example) do - @key = OpenSSL::PKey::RSA.new(2048) - @client = clz.new(StringIO.new(cred_json_text)) - end - - def cred_json_text - cred_json = { + let(:cred_json) do + { private_key_id: 'a_private_key_id', private_key: @key.to_pem, client_email: client_email, client_id: 'app.apps.googleusercontent.com', type: 'service_account' } + end + + before(:example) do + @key = OpenSSL::PKey::RSA.new(2048) + @client = clz.new(json_key_io: StringIO.new(cred_json_text)) + end + + def cred_json_text MultiJson.dump(cred_json) end @@ -246,11 +263,15 @@ describe Google::Auth::ServiceAccountJwtHeaderCredentials do describe '#from_env' do before(:example) do @var_name = ENV_VAR - @orig = ENV[@var_name] + @credential_vars = [ + ENV_VAR, PRIVATE_KEY_VAR, CLIENT_EMAIL_VAR, ACCOUNT_TYPE_VAR] + @original_env_vals = {} + @credential_vars.each { |var| @original_env_vals[var] = ENV[var] } + ENV[ACCOUNT_TYPE_VAR] = cred_json[:type] end after(:example) do - ENV[@var_name] = @orig unless @orig.nil? + @credential_vars.each { |var| ENV[var] = @original_env_vals[var] } end it 'returns nil if the GOOGLE_APPLICATION_CREDENTIALS is unset' do @@ -277,6 +298,13 @@ describe Google::Auth::ServiceAccountJwtHeaderCredentials do expect(clz.from_env).to_not be_nil end end + + it 'succeeds when GOOGLE_PRIVATE_KEY and GOOGLE_CLIENT_EMAIL env vars are'\ + ' valid' do + ENV[PRIVATE_KEY_VAR] = cred_json[:private_key] + ENV[CLIENT_EMAIL_VAR] = cred_json[:client_email] + expect(clz.from_env(@scope)).to_not be_nil + end end describe '#from_well_known_path' do diff --git a/spec/googleauth/user_refresh_spec.rb b/spec/googleauth/user_refresh_spec.rb index 0765f87..64047ec 100644 --- a/spec/googleauth/user_refresh_spec.rb +++ b/spec/googleauth/user_refresh_spec.rb @@ -40,15 +40,26 @@ require 'openssl' require 'spec_helper' require 'tmpdir' +include Google::Auth::CredentialsLoader + describe Google::Auth::UserRefreshCredentials do UserRefreshCredentials = Google::Auth::UserRefreshCredentials - CredentialsLoader = Google::Auth::CredentialsLoader + + let(:cred_json) do + { + client_secret: 'privatekey', + client_id: 'client123', + refresh_token: 'refreshtoken', + type: 'authorized_user' + } + end before(:example) do @key = OpenSSL::PKey::RSA.new(2048) @client = UserRefreshCredentials.new( - StringIO.new(cred_json_text), - 'https://www.googleapis.com/auth/userinfo.profile') + json_key_io: StringIO.new(cred_json_text), + scope: 'https://www.googleapis.com/auth/userinfo.profile' + ) end def make_auth_stubs(opts = {}) @@ -64,12 +75,6 @@ describe Google::Auth::UserRefreshCredentials do end def cred_json_text(missing = nil) - cred_json = { - client_secret: 'privatekey', - client_id: 'client123', - refresh_token: 'refreshtoken', - type: 'authorized_user' - } cred_json.delete(missing.to_sym) unless missing.nil? MultiJson.dump(cred_json) end @@ -78,14 +83,18 @@ describe Google::Auth::UserRefreshCredentials do describe '#from_env' do before(:example) do - @var_name = CredentialsLoader::ENV_VAR - @orig = ENV[@var_name] + @var_name = ENV_VAR + @credential_vars = [ + ENV_VAR, CLIENT_ID_VAR, CLIENT_SECRET_VAR, REFRESH_TOKEN_VAR, + ACCOUNT_TYPE_VAR] + @original_env_vals = {} + @credential_vars.each { |var| @original_env_vals[var] = ENV[var] } @scope = 'https://www.googleapis.com/auth/userinfo.profile' @clz = UserRefreshCredentials end after(:example) do - ENV[@var_name] = @orig unless @orig.nil? + @credential_vars.each { |var| ENV[var] = @original_env_vals[var] } end it 'returns nil if the GOOGLE_APPLICATION_CREDENTIALS is unset' do @@ -125,13 +134,22 @@ describe Google::Auth::UserRefreshCredentials do expect(@clz.from_env(@scope)).to_not be_nil end end + + it 'succeeds when GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and '\ + 'GOOGLE_REFRESH_TOKEN env vars are valid' do + ENV[CLIENT_ID_VAR] = cred_json[:client_id] + ENV[CLIENT_SECRET_VAR] = cred_json[:client_secret] + ENV[REFRESH_TOKEN_VAR] = cred_json[:refresh_token] + ENV[ACCOUNT_TYPE_VAR] = cred_json[:type] + expect(@clz.from_env(@scope)).to_not be_nil + end end describe '#from_well_known_path' do before(:example) do @home = ENV['HOME'] @scope = 'https://www.googleapis.com/auth/userinfo.profile' - @known_path = CredentialsLoader::WELL_KNOWN_PATH + @known_path = WELL_KNOWN_PATH @clz = UserRefreshCredentials end @@ -152,7 +170,7 @@ describe Google::Auth::UserRefreshCredentials do FileUtils.mkdir_p(File.dirname(key_path)) File.write(key_path, cred_json_text(missing)) ENV['HOME'] = dir - expect { @clz.from_env(@scope) }.to raise_error + expect { @clz.from_well_known_path(@scope) }.to raise_error end end end