diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 076f5b1..8018e2e 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -36,7 +36,8 @@ module Google # Module Auth provides classes that provide Google-specific authorization # used to access Google APIs. module Auth - # Authenticates requests using Google's Service Account credentials. + # Authenticates requests using Google's Service Account credentials via an + # OAuth access token. # # This class allows authorizing requests for service accounts directly # from credentials from a json key file downloaded from the developer @@ -44,8 +45,6 @@ module Google # # cf [Application Default Credentials](http://goo.gl/mkAHpZ) class ServiceAccountCredentials < Signet::OAuth2::Client - JWT_AUD_URI_KEY = :jwt_aud_uri - AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token' extend CredentialsLoader @@ -70,33 +69,94 @@ module Google issuer: client_email, signing_key: OpenSSL::PKey::RSA.new(private_key)) end + end - # Extends the superclass behaviour to construct a jwt token if the - # google jwt uri key is present in the input hash. + # Authenticates requests using Google's Service Account credentials via + # JWT Header. + # + # This class allows authorizing requests for service accounts directly + # from credentials from a json key file downloaded from the developer + # console (via 'Generate new Json Key'). It is not part of any OAuth2 + # flow, rather it creates a JWT and sends that as a credential. + # + # cf [Application Default Credentials](http://goo.gl/mkAHpZ) + class ServiceAccountJwtHeaderCredentials + JWT_AUD_URI_KEY = :jwt_aud_uri + AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY + TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token' + SIGNING_ALGORITHM = 'RS256' + EXPIRY = 60 + extend CredentialsLoader + + # make_creds proxies the construction of a credentials instance # - # The jwt is used as the authentication token. + # make_creds is used by the methods in CredentialsLoader. + # + # By default, it calls #new with 2 args, the second one being an + # 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]) + end + + # Reads the private key and client email fields from the service account + # JSON key. + def self.read_json_key(json_key_io) + json_key = MultiJson.load(json_key_io.read) + fail 'missing client_email' unless json_key.key?('client_email') + fail 'missing private_key' unless json_key.key?('private_key') + [json_key['private_key'], json_key['client_email']] + end + + # 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) + @private_key = private_key + @issuer = client_email + @signing_key = OpenSSL::PKey::RSA.new(private_key) + end + + # Construct a jwt token if the WT_AUD_URI key is present in the input + # hash. + # + # The jwt token is used as the value of a 'Bearer '. def apply!(a_hash, opts = {}) jwt_aud_uri = a_hash.delete(JWT_AUD_URI_KEY) - unless jwt_aud_uri.nil? - jwt_token = new_jwt_token(jwt_aud_uri, opts) - a_hash[AUTH_METADATA_KEY] = "Bearer #{jwt_token}" - return a_hash - end - super + return a_hash if jwt_aud_uri.nil? + jwt_token = new_jwt_token(jwt_aud_uri, opts) + a_hash[AUTH_METADATA_KEY] = "Bearer #{jwt_token}" + a_hash end + # Returns a clone of a_hash updated with the authoriation header + 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 + lambda(&method(:apply)) + end + + protected + # Creates a jwt uri token. def new_jwt_token(jwt_aud_uri, options = {}) now = Time.new skew = options[:skew] || 60 assertion = { - 'iss' => issuer, - 'sub' => issuer, + 'iss' => @issuer, + 'sub' => @issuer, 'aud' => jwt_aud_uri, - 'exp' => (now + expiry).to_i, + 'exp' => (now + EXPIRY).to_i, 'iat' => (now - skew).to_i } - JWT.encode(assertion, signing_key, signing_algorithm) + JWT.encode(assertion, @signing_key, SIGNING_ALGORITHM) end end end diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 6992b36..ee0f535 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -80,67 +80,6 @@ describe Google::Auth::ServiceAccountCredentials do it_behaves_like 'apply/apply! are OK' - context 'when jwt_aud_uri is present' do - WANTED_AUTH_KEY = ServiceAccountCredentials::AUTH_METADATA_KEY - JWT_AUD_URI_KEY = ServiceAccountCredentials::JWT_AUD_URI_KEY - let(:test_uri) { 'https://www.googleapis.com/myservice' } - let(:auth_prefix) { 'Bearer ' } - - def expect_is_encoded_jwt(hdr) - expect(hdr).to_not be_nil - expect(hdr.start_with?(auth_prefix)).to be true - authorization = hdr[auth_prefix.length..-1] - payload, _ = JWT.decode(authorization, @key.public_key) - expect(payload['aud']).to eq(test_uri) - expect(payload['iss']).to eq(client_email) - end - - describe '#apply!' do - it 'should update the target hash with a jwt token' do - md = { foo: 'bar' } - md[JWT_AUD_URI_KEY] = test_uri - @client.apply!(md) - auth_header = md[WANTED_AUTH_KEY] - expect_is_encoded_jwt(auth_header) - expect(md[JWT_AUD_URI_KEY]).to be_nil - end - end - - describe 'updater_proc' do - it 'should provide a proc that updates a hash with a jwt token' do - md = { foo: 'bar' } - md[JWT_AUD_URI_KEY] = test_uri - the_proc = @client.updater_proc - got = the_proc.call(md) - auth_header = got[WANTED_AUTH_KEY] - expect_is_encoded_jwt(auth_header) - expect(got[JWT_AUD_URI_KEY]).to be_nil - expect(md[JWT_AUD_URI_KEY]).to_not be_nil - end - end - - describe '#apply' do - it 'should not update the original hash with a jwt token' do - md = { foo: 'bar' } - md[JWT_AUD_URI_KEY] = test_uri - the_proc = @client.updater_proc - got = the_proc.call(md) - auth_header = md[WANTED_AUTH_KEY] - expect(auth_header).to be_nil - expect(got[JWT_AUD_URI_KEY]).to be_nil - expect(md[JWT_AUD_URI_KEY]).to_not be_nil - end - - it 'should add a jwt token to the returned hash' do - md = { foo: 'bar' } - md[JWT_AUD_URI_KEY] = test_uri - got = @client.apply(md) - auth_header = got[WANTED_AUTH_KEY] - expect_is_encoded_jwt(auth_header) - end - end - end - describe '#from_env' do before(:example) do @var_name = CredentialsLoader::ENV_VAR @@ -207,3 +146,151 @@ describe Google::Auth::ServiceAccountCredentials do end end end + +describe Google::Auth::ServiceAccountJwtHeaderCredentials do + CredentialsLoader = Google::Auth::CredentialsLoader + ServiceAccountJwtHeaderCredentials = + Google::Auth::ServiceAccountJwtHeaderCredentials + + 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 = { + 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 + + context 'when jwt_aud_uri is present' do + WANTED_AUTH_KEY = ServiceAccountJwtHeaderCredentials::AUTH_METADATA_KEY + JWT_AUD_URI_KEY = ServiceAccountJwtHeaderCredentials::JWT_AUD_URI_KEY + let(:test_uri) { 'https://www.googleapis.com/myservice' } + let(:auth_prefix) { 'Bearer ' } + + def expect_is_encoded_jwt(hdr) + expect(hdr).to_not be_nil + expect(hdr.start_with?(auth_prefix)).to be true + authorization = hdr[auth_prefix.length..-1] + payload, _ = JWT.decode(authorization, @key.public_key) + expect(payload['aud']).to eq(test_uri) + expect(payload['iss']).to eq(client_email) + end + + describe '#apply!' do + it 'should update the target hash with a jwt token' do + md = { foo: 'bar' } + md[JWT_AUD_URI_KEY] = test_uri + @client.apply!(md) + auth_header = md[WANTED_AUTH_KEY] + expect_is_encoded_jwt(auth_header) + expect(md[JWT_AUD_URI_KEY]).to be_nil + end + end + + describe 'updater_proc' do + it 'should provide a proc that updates a hash with a jwt token' do + md = { foo: 'bar' } + md[JWT_AUD_URI_KEY] = test_uri + the_proc = @client.updater_proc + got = the_proc.call(md) + auth_header = got[WANTED_AUTH_KEY] + expect_is_encoded_jwt(auth_header) + expect(got[JWT_AUD_URI_KEY]).to be_nil + expect(md[JWT_AUD_URI_KEY]).to_not be_nil + end + end + + describe '#apply' do + it 'should not update the original hash with a jwt token' do + md = { foo: 'bar' } + md[JWT_AUD_URI_KEY] = test_uri + the_proc = @client.updater_proc + got = the_proc.call(md) + auth_header = md[WANTED_AUTH_KEY] + expect(auth_header).to be_nil + expect(got[JWT_AUD_URI_KEY]).to be_nil + expect(md[JWT_AUD_URI_KEY]).to_not be_nil + end + + it 'should add a jwt token to the returned hash' do + md = { foo: 'bar' } + md[JWT_AUD_URI_KEY] = test_uri + got = @client.apply(md) + auth_header = got[WANTED_AUTH_KEY] + expect_is_encoded_jwt(auth_header) + end + end + end + + describe '#from_env' do + before(:example) do + @var_name = CredentialsLoader::ENV_VAR + @orig = ENV[@var_name] + end + + after(:example) do + ENV[@var_name] = @orig unless @orig.nil? + end + + it 'returns nil if the GOOGLE_APPLICATION_CREDENTIALS is unset' do + ENV.delete(@var_name) unless ENV[@var_name].nil? + expect(clz.from_env).to be_nil + end + + it 'fails if the GOOGLE_APPLICATION_CREDENTIALS path does not exist' do + ENV.delete(@var_name) unless ENV[@var_name].nil? + expect(clz.from_env).to be_nil + Dir.mktmpdir do |dir| + key_path = File.join(dir, 'does-not-exist') + ENV[@var_name] = key_path + expect { clz.from_env }.to raise_error + end + end + + it 'succeeds when the GOOGLE_APPLICATION_CREDENTIALS file is valid' 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 + expect(clz.from_env).to_not be_nil + end + end + end + + describe '#from_well_known_path' do + before(:example) do + @home = ENV['HOME'] + @known_path = CredentialsLoader::WELL_KNOWN_PATH + end + + after(:example) do + ENV['HOME'] = @home unless @home == ENV['HOME'] + end + + it 'is nil if no file exists' do + ENV['HOME'] = File.dirname(__FILE__) + expect(clz.from_well_known_path).to be_nil + end + + it 'successfully loads the file when it is present' do + Dir.mktmpdir do |dir| + key_path = File.join(dir, '.config', @known_path) + FileUtils.mkdir_p(File.dirname(key_path)) + File.write(key_path, cred_json_text) + ENV['HOME'] = dir + expect(clz.from_well_known_path).to_not be_nil + end + end + end +end