diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 543d13f..8c99036 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,15 +1,15 @@ # This configuration was generated by `rubocop --auto-gen-config` -# on 2015-02-25 04:34:33 -0800 using RuboCop version 0.28.0. +# on 2015-03-06 19:51:00 -0800 using RuboCop version 0.28.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 # versions of RuboCop, may require this file to be generated again. -# Offense count: 1 +# Offense count: 3 Metrics/AbcSize: - Max: 16 + Max: 24 -# Offense count: 1 +# Offense count: 3 # Configuration parameters: CountComments. Metrics/MethodLength: Max: 11 diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 66d7be6..c74d3cc 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -29,13 +29,16 @@ require 'googleauth/signet' require 'googleauth/credentials_loader' +require 'jwt' require 'multi_json' +require 'stringio' 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 @@ -67,6 +70,118 @@ module Google issuer: client_email, signing_key: OpenSSL::PKey::RSA.new(private_key)) end + + # Extends the base class. + # + # If scope(s) is not set, it creates a transient + # ServiceAccountJwtHeaderCredentials instance and uses that to + # authenticate instead. + def apply!(a_hash, opts = {}) + # Use the base implementation if scopes are set + unless scope.nil? + super + return + end + + # Use the ServiceAccountJwtHeaderCredentials using the same cred values + # if no scopes are set. + cred_json = { + private_key: @signing_key.to_s, + client_email: @issuer + } + alt_clz = ServiceAccountJwtHeaderCredentials + alt = alt_clz.new(StringIO.new(MultiJson.dump(cred_json))) + alt.apply!(a_hash) + end + end + + # 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 + # + # 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 JWT_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) + 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, + 'aud' => jwt_aud_uri, + 'exp' => (now + EXPIRY).to_i, + 'iat' => (now - skew).to_i + } + JWT.encode(assertion, @signing_key, SIGNING_ALGORITHM) + end end end end diff --git a/spec/googleauth/apply_auth_examples.rb b/spec/googleauth/apply_auth_examples.rb index fcafa9e..a0eca97 100644 --- a/spec/googleauth/apply_auth_examples.rb +++ b/spec/googleauth/apply_auth_examples.rb @@ -46,9 +46,9 @@ def build_access_token_json(token) 'expires_in' => 3600) end -WANTED_AUTH_KEY = :Authorization - shared_examples 'apply/apply! are OK' do + let(:auth_key) { :Authorization } + # tests that use these examples need to define # # @client which should be an auth client @@ -79,7 +79,7 @@ shared_examples 'apply/apply! are OK' do md = { foo: 'bar' } @client.apply!(md, connection: c) - want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer #{token}" } + want = { :foo => 'bar', auth_key => "Bearer #{token}" } expect(md).to eq(want) stubs.verify_stubbed_calls end @@ -96,7 +96,7 @@ shared_examples 'apply/apply! are OK' do md = { foo: 'bar' } the_proc = @client.updater_proc got = the_proc.call(md, connection: c) - want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer #{token}" } + want = { :foo => 'bar', auth_key => "Bearer #{token}" } expect(got).to eq(want) stubs.verify_stubbed_calls end @@ -126,7 +126,7 @@ shared_examples 'apply/apply! are OK' do md = { foo: 'bar' } got = @client.apply(md, connection: c) - want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer #{token}" } + want = { :foo => 'bar', auth_key => "Bearer #{token}" } expect(got).to eq(want) stubs.verify_stubbed_calls end @@ -142,7 +142,7 @@ shared_examples 'apply/apply! are OK' do n.times do |_t| md = { foo: 'bar' } got = @client.apply(md, connection: c) - want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer #{token}" } + want = { :foo => 'bar', auth_key => "Bearer #{token}" } expect(got).to eq(want) end stubs.verify_stubbed_calls @@ -159,7 +159,7 @@ shared_examples 'apply/apply! are OK' do end md = { foo: 'bar' } got = @client.apply(md, connection: c) - want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer #{t}" } + want = { :foo => 'bar', auth_key => "Bearer #{t}" } expect(got).to eq(want) stubs.verify_stubbed_calls @client.expires_at -= 3601 # default is to expire in 1hr diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 108e2b0..7d5570a 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -40,9 +40,74 @@ require 'openssl' require 'spec_helper' require 'tmpdir' +include Google::Auth::CredentialsLoader + +shared_examples 'jwt header auth' do + context 'when jwt_aud_uri is present' do + let(:test_uri) { 'https://www.googleapis.com/myservice' } + let(:auth_prefix) { 'Bearer ' } + let(:auth_key) { ServiceAccountJwtHeaderCredentials::AUTH_METADATA_KEY } + let(:jwt_uri_key) { ServiceAccountJwtHeaderCredentials::JWT_AUD_URI_KEY } + + 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_uri_key] = test_uri + @client.apply!(md) + auth_header = md[auth_key] + expect_is_encoded_jwt(auth_header) + expect(md[jwt_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_uri_key] = test_uri + the_proc = @client.updater_proc + got = the_proc.call(md) + auth_header = got[auth_key] + expect_is_encoded_jwt(auth_header) + expect(got[jwt_uri_key]).to be_nil + expect(md[jwt_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_uri_key] = test_uri + the_proc = @client.updater_proc + got = the_proc.call(md) + auth_header = md[auth_key] + expect(auth_header).to be_nil + expect(got[jwt_uri_key]).to be_nil + expect(md[jwt_uri_key]).to_not be_nil + end + + it 'should add a jwt token to the returned hash' do + md = { foo: 'bar' } + md[jwt_uri_key] = test_uri + got = @client.apply(md) + auth_header = got[auth_key] + expect_is_encoded_jwt(auth_header) + end + end + end +end + describe Google::Auth::ServiceAccountCredentials do ServiceAccountCredentials = Google::Auth::ServiceAccountCredentials - CredentialsLoader = Google::Auth::CredentialsLoader + let(:client_email) { 'app@developer.gserviceaccount.com' } before(:example) do @key = OpenSSL::PKey::RSA.new(2048) @@ -69,7 +134,7 @@ describe Google::Auth::ServiceAccountCredentials do cred_json = { private_key_id: 'a_private_key_id', private_key: @key.to_pem, - client_email: 'app@developer.gserviceaccount.com', + client_email: client_email, client_id: 'app.apps.googleusercontent.com', type: 'service_account' } @@ -78,9 +143,17 @@ describe Google::Auth::ServiceAccountCredentials do it_behaves_like 'apply/apply! are OK' + context 'when scope is nil' do + before(:example) do + @client.scope = nil + end + + it_behaves_like 'jwt header auth' + end + describe '#from_env' do before(:example) do - @var_name = CredentialsLoader::ENV_VAR + @var_name = ENV_VAR @orig = ENV[@var_name] @scope = 'https://www.googleapis.com/auth/userinfo.profile' @clz = ServiceAccountCredentials @@ -120,7 +193,7 @@ describe Google::Auth::ServiceAccountCredentials 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 = ServiceAccountCredentials end @@ -144,3 +217,90 @@ describe Google::Auth::ServiceAccountCredentials do end end end + +describe Google::Auth::ServiceAccountJwtHeaderCredentials do + 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 + + it_behaves_like 'jwt header auth' + + describe '#from_env' do + before(:example) do + @var_name = 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'] + 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', WELL_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