From 720bc81c899cc9c75f73984a8e2a2498b31a1604 Mon Sep 17 00:00:00 2001 From: Tim Emiola Date: Fri, 30 Jan 2015 17:56:25 -0800 Subject: [PATCH] Adds a signet based service_account creds implementation --- src/ruby/grpc.gemspec | 1 + src/ruby/lib/grpc/auth/service_account.rb | 68 ++++++++++ src/ruby/lib/grpc/auth/signet.rb | 5 +- src/ruby/spec/auth/apply_auth_examples.rb | 147 +++++++++++++++++++++ src/ruby/spec/auth/service_account_spec.rb | 75 +++++++++++ src/ruby/spec/auth/signet_spec.rb | 145 +++----------------- 6 files changed, 316 insertions(+), 125 deletions(-) create mode 100644 src/ruby/lib/grpc/auth/service_account.rb create mode 100644 src/ruby/spec/auth/apply_auth_examples.rb create mode 100644 src/ruby/spec/auth/service_account_spec.rb diff --git a/src/ruby/grpc.gemspec b/src/ruby/grpc.gemspec index 0eb8f48d843..2ce242dd0b1 100755 --- a/src/ruby/grpc.gemspec +++ b/src/ruby/grpc.gemspec @@ -25,6 +25,7 @@ Gem::Specification.new do |s| s.add_dependency 'logging', '~> 1.8' s.add_dependency 'jwt', '~> 1.2.1' s.add_dependency 'minitest', '~> 5.4' # reqd for interop tests + s.add_dependency 'multijson', '1.10.1' s.add_dependency 'signet', '~> 0.6.0' s.add_dependency 'xray', '~> 1.1' diff --git a/src/ruby/lib/grpc/auth/service_account.rb b/src/ruby/lib/grpc/auth/service_account.rb new file mode 100644 index 00000000000..35b5cbfe2de --- /dev/null +++ b/src/ruby/lib/grpc/auth/service_account.rb @@ -0,0 +1,68 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +require 'grpc/auth/signet' +require 'multi_json' +require 'openssl' + +# Reads the private key and client email fields from service account JSON key. +def 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 + +module Google + module RPC + # Module Auth provides classes that provide Google-specific authentication + # used to access Google gRPC services. + module Auth + # Authenticates requests using Google's Service Account credentials. + # (cf https://developers.google.com/accounts/docs/OAuth2ServiceAccount) + class ServiceAccountCredentials < Signet::OAuth2::Client + TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token' + AUDIENCE = TOKEN_CRED_URI + + # Initializes a ServiceAccountCredentials. + # + # @param scope [string|array] the scope(s) to access + # @param json_key_io [IO] an IO from which the JSON key can be read + def initialize(scope, json_key_io) + private_key, client_email = read_json_key(json_key_io) + super(token_credential_uri: TOKEN_CRED_URI, + audience: AUDIENCE, + scope: scope, + issuer: client_email, + signing_key: OpenSSL::PKey::RSA.new(private_key)) + end + end + end + end +end diff --git a/src/ruby/lib/grpc/auth/signet.rb b/src/ruby/lib/grpc/auth/signet.rb index 9cc51b7b3c5..b46af1696af 100644 --- a/src/ruby/lib/grpc/auth/signet.rb +++ b/src/ruby/lib/grpc/auth/signet.rb @@ -31,7 +31,8 @@ require 'signet/oauth_2/client' module Signet module OAuth2 - # Google::RPC creates an OAuth2 client + AUTH_METADATA_KEY = :Authorization + # Signet::OAuth2::Client creates an OAuth2 client # # Here client is re-opened to add the #apply and #apply! methods which # update a hash map with the fetched authentication token @@ -45,7 +46,7 @@ module Signet # fetch the access token there is currently not one, or if the client # has expired fetch_access_token!(opts) if access_token.nil? || expired? - a_hash['auth'] = access_token + a_hash[AUTH_METADATA_KEY] = "Bearer: #{access_token}" end # Returns a clone of a_hash updated with the authentication token diff --git a/src/ruby/spec/auth/apply_auth_examples.rb b/src/ruby/spec/auth/apply_auth_examples.rb new file mode 100644 index 00000000000..af1f6df04ae --- /dev/null +++ b/src/ruby/spec/auth/apply_auth_examples.rb @@ -0,0 +1,147 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'faraday' +require 'spec_helper' + +def build_json_response(payload) + [200, + { 'Content-Type' => 'application/json; charset=utf-8' }, + MultiJson.dump(payload)] +end + +WANTED_AUTH_KEY = :Authorization + +shared_examples 'apply/apply! are OK' do + + # tests that use these examples need to define + # + # @client which should be an auth client + # + # @make_auth_stubs, which should stub out the expected http behaviour of the + # auth client + describe '#fetch_access_token' do + it 'should set access_token to the fetched value' do + token = '1/abcdef1234567890' + stubs = make_auth_stubs with_access_token: token + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + + @client.fetch_access_token!(connection: c) + expect(@client.access_token).to eq(token) + stubs.verify_stubbed_calls + end + end + + describe '#apply!' do + it 'should update the target hash with fetched access token' do + token = '1/abcdef1234567890' + stubs = make_auth_stubs with_access_token: token + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + + md = { foo: 'bar' } + @client.apply!(md, connection: c) + want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer: #{token}" } + expect(md).to eq(want) + stubs.verify_stubbed_calls + end + end + + describe '#apply' do + it 'should not update the original hash with the access token' do + token = '1/abcdef1234567890' + stubs = make_auth_stubs with_access_token: token + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + + md = { foo: 'bar' } + @client.apply(md, connection: c) + want = { foo: 'bar' } + expect(md).to eq(want) + stubs.verify_stubbed_calls + end + + it 'should add the token to the returned hash' do + token = '1/abcdef1234567890' + stubs = make_auth_stubs with_access_token: token + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + + md = { foo: 'bar' } + got = @client.apply(md, connection: c) + want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer: #{token}" } + expect(got).to eq(want) + stubs.verify_stubbed_calls + end + + it 'should not fetch a new token if the current is not expired' do + token = '1/abcdef1234567890' + stubs = make_auth_stubs with_access_token: token + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + + n = 5 # arbitrary + n.times do |_t| + md = { foo: 'bar' } + got = @client.apply(md, connection: c) + want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer: #{token}" } + expect(got).to eq(want) + end + stubs.verify_stubbed_calls + end + + it 'should fetch a new token if the current one is expired' do + token_1 = '1/abcdef1234567890' + token_2 = '2/abcdef1234567890' + + [token_1, token_2].each do |t| + stubs = make_auth_stubs with_access_token: t + c = Faraday.new do |b| + b.adapter(:test, stubs) + end + md = { foo: 'bar' } + got = @client.apply(md, connection: c) + want = { :foo => 'bar', WANTED_AUTH_KEY => "Bearer: #{t}" } + expect(got).to eq(want) + stubs.verify_stubbed_calls + @client.expires_at -= 3601 # default is to expire in 1hr + end + end + end +end diff --git a/src/ruby/spec/auth/service_account_spec.rb b/src/ruby/spec/auth/service_account_spec.rb new file mode 100644 index 00000000000..cbc6a73ac20 --- /dev/null +++ b/src/ruby/spec/auth/service_account_spec.rb @@ -0,0 +1,75 @@ +# Copyright 2015, Google Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following disclaimer +# in the documentation and/or other materials provided with the +# distribution. +# * Neither the name of Google Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) +$LOAD_PATH.unshift(spec_dir) +$LOAD_PATH.uniq! + +require 'apply_auth_examples' +require 'grpc/auth/service_account' +require 'jwt' +require 'multi_json' +require 'openssl' +require 'spec_helper' + +describe Google::RPC::Auth::ServiceAccountCredentials do + before(:example) do + @key = OpenSSL::PKey::RSA.new(2048) + cred_json = { + 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' + } + cred_json_text = MultiJson.dump(cred_json) + @client = Google::RPC::Auth::ServiceAccountCredentials.new( + 'https://www.googleapis.com/auth/userinfo.profile', + StringIO.new(cred_json_text)) + end + + def make_auth_stubs(with_access_token: '') + Faraday::Adapter::Test::Stubs.new do |stub| + stub.post('/oauth2/v3/token') do |env| + params = Addressable::URI.form_unencode(env[:body]) + _claim, _header = JWT.decode(params.assoc('assertion').last, + @key.public_key) + want = ['grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'] + expect(params.assoc('grant_type')).to eq(want) + build_json_response( + 'access_token' => with_access_token, + 'token_type' => 'Bearer', + 'expires_in' => 3600 + ) + end + end + end + + it_behaves_like 'apply/apply! are OK' +end diff --git a/src/ruby/spec/auth/signet_spec.rb b/src/ruby/spec/auth/signet_spec.rb index d658e5c5448..1712edf2961 100644 --- a/src/ruby/spec/auth/signet_spec.rb +++ b/src/ruby/spec/auth/signet_spec.rb @@ -31,141 +31,40 @@ spec_dir = File.expand_path(File.join(File.dirname(__FILE__))) $LOAD_PATH.unshift(spec_dir) $LOAD_PATH.uniq! -require 'spec_helper' - +require 'apply_auth_examples' require 'grpc/auth/signet' -require 'openssl' require 'jwt' - -def build_json_response(payload) - [200, - { 'Content-Type' => 'application/json; charset=utf-8' }, - MultiJson.dump(payload)] -end +require 'openssl' +require 'spec_helper' describe Signet::OAuth2::Client do - describe 'when using RSA keys' do - before do - @key = OpenSSL::PKey::RSA.new(2048) - @client = Signet::OAuth2::Client.new( + before(:example) do + @key = OpenSSL::PKey::RSA.new(2048) + @client = Signet::OAuth2::Client.new( token_credential_uri: 'https://accounts.google.com/o/oauth2/token', scope: 'https://www.googleapis.com/auth/userinfo.profile', issuer: 'app@example.com', audience: 'https://accounts.google.com/o/oauth2/token', signing_key: @key ) - end - - def make_oauth_stubs(with_access_token: '') - Faraday::Adapter::Test::Stubs.new do |stub| - stub.post('/o/oauth2/token') do |env| - params = Addressable::URI.form_unencode(env[:body]) - _claim, _header = JWT.decode(params.assoc('assertion').last, - @key.public_key) - want = ['grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'] - expect(params.assoc('grant_type')).to eq(want) - build_json_response( - 'access_token' => with_access_token, - 'token_type' => 'Bearer', - 'expires_in' => 3600 - ) - end - end - end - - describe '#fetch_access_token' do - it 'should set access_token to the fetched value' do - token = '1/abcdef1234567890' - stubs = make_oauth_stubs with_access_token: token - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - - @client.fetch_access_token!(connection: c) - expect(@client.access_token).to eq(token) - stubs.verify_stubbed_calls - end - end - - describe '#apply!' do - it 'should update the target hash with fetched access token' do - token = '1/abcdef1234567890' - stubs = make_oauth_stubs with_access_token: token - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - - md = { foo: 'bar' } - @client.apply!(md, connection: c) - want = { :foo => 'bar', 'auth' => token } - expect(md).to eq(want) - stubs.verify_stubbed_calls - end - end - - describe '#apply' do - it 'should not update the original hash with the access token' do - token = '1/abcdef1234567890' - stubs = make_oauth_stubs with_access_token: token - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - - md = { foo: 'bar' } - @client.apply(md, connection: c) - want = { foo: 'bar' } - expect(md).to eq(want) - stubs.verify_stubbed_calls - end - - it 'should add the token to the returned hash' do - token = '1/abcdef1234567890' - stubs = make_oauth_stubs with_access_token: token - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - - md = { foo: 'bar' } - got = @client.apply(md, connection: c) - want = { :foo => 'bar', 'auth' => token } - expect(got).to eq(want) - stubs.verify_stubbed_calls - end - - it 'should not fetch a new token if the current is not expired' do - token = '1/abcdef1234567890' - stubs = make_oauth_stubs with_access_token: token - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - - n = 5 # arbitrary - n.times do |_t| - md = { foo: 'bar' } - got = @client.apply(md, connection: c) - want = { :foo => 'bar', 'auth' => token } - expect(got).to eq(want) - end - stubs.verify_stubbed_calls - end - - it 'should fetch a new token if the current one is expired' do - token_1 = '1/abcdef1234567890' - token_2 = '2/abcdef1234567890' + end - [token_1, token_2].each do |t| - stubs = make_oauth_stubs with_access_token: t - c = Faraday.new(url: 'https://www.google.com') do |b| - b.adapter(:test, stubs) - end - md = { foo: 'bar' } - got = @client.apply(md, connection: c) - want = { :foo => 'bar', 'auth' => t } - expect(got).to eq(want) - stubs.verify_stubbed_calls - @client.expires_at -= 3601 # default is to expire in 1hr - end + def make_auth_stubs(with_access_token: '') + Faraday::Adapter::Test::Stubs.new do |stub| + stub.post('/o/oauth2/token') do |env| + params = Addressable::URI.form_unencode(env[:body]) + _claim, _header = JWT.decode(params.assoc('assertion').last, + @key.public_key) + want = ['grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'] + expect(params.assoc('grant_type')).to eq(want) + build_json_response( + 'access_token' => with_access_token, + 'token_type' => 'Bearer', + 'expires_in' => 3600 + ) end end end + + it_behaves_like 'apply/apply! are OK' end