From d22acb8a510e6711b5674545c31a4816e5a9168f Mon Sep 17 00:00:00 2001 From: Daniel Azuma Date: Mon, 14 Dec 2020 02:40:55 +0000 Subject: [PATCH] feat: Service accounts apply a self-signed JWT if scopes are marked as default --- lib/googleauth/credentials.rb | 9 +- lib/googleauth/service_account.rb | 50 ++++---- spec/googleauth/credentials_spec.rb | 148 ++++++++++++++++-------- spec/googleauth/service_account_spec.rb | 8 ++ 4 files changed, 146 insertions(+), 69 deletions(-) diff --git a/lib/googleauth/credentials.rb b/lib/googleauth/credentials.rb index 9739882a..bd4ee21c 100644 --- a/lib/googleauth/credentials.rb +++ b/lib/googleauth/credentials.rb @@ -75,7 +75,7 @@ module Auth # creds2 = SubCredentials.default # creds2.scope # => ["http://example.com/sub_scope"] # - class Credentials + class Credentials # rubocop:disable Metrics/ClassLength ## # The default token credential URI to be used when none is provided during initialization. TOKEN_CREDENTIAL_URI = "https://oauth2.googleapis.com/token".freeze @@ -426,7 +426,12 @@ def self.from_default_paths options # @private Lookup Credentials using Google::Auth.get_application_default. def self.from_application_default options scope = options[:scope] || self.scope - auth_opts = { target_audience: options[:target_audience] || target_audience } + auth_opts = { + token_credential_uri: options[:token_credential_uri] || token_credential_uri, + audience: options[:audience] || audience, + target_audience: options[:target_audience] || target_audience, + enable_self_signed_jwt: options[:enable_self_signed_jwt] && options[:scope].nil? + } client = Google::Auth.get_application_default scope, auth_opts new client, options end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index d7225186..09cfa46e 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -53,12 +53,18 @@ class ServiceAccountCredentials < Signet::OAuth2::Client attr_reader :project_id attr_reader :quota_project_id + def enable_self_signed_jwt? + @enable_self_signed_jwt + end + # Creates a ServiceAccountCredentials. # # @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 self.make_creds options = {} - json_key_io, scope, target_audience = options.values_at :json_key_io, :scope, :target_audience + json_key_io, scope, enable_self_signed_jwt, target_audience, audience, token_credential_uri = + options.values_at :json_key_io, :scope, :enable_self_signed_jwt, :target_audience, + :audience, :token_credential_uri raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience if json_key_io @@ -71,14 +77,15 @@ def self.make_creds options = {} end project_id ||= CredentialsLoader.load_gcloud_project_id - new(token_credential_uri: TOKEN_CRED_URI, - audience: TOKEN_CRED_URI, - scope: scope, - target_audience: target_audience, - issuer: client_email, - signing_key: OpenSSL::PKey::RSA.new(private_key), - project_id: project_id, - quota_project_id: quota_project_id) + new(token_credential_uri: token_credential_uri || TOKEN_CRED_URI, + audience: audience || TOKEN_CRED_URI, + scope: scope, + enable_self_signed_jwt: enable_self_signed_jwt, + target_audience: target_audience, + issuer: client_email, + signing_key: OpenSSL::PKey::RSA.new(private_key), + project_id: project_id, + quota_project_id: quota_project_id) .configure_connection(options) end @@ -94,30 +101,33 @@ def self.unescape str def initialize options = {} @project_id = options[:project_id] @quota_project_id = options[:quota_project_id] + @enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false super options end - # Extends the base class. - # - # If scope(s) is not set, it creates a transient - # ServiceAccountJwtHeaderCredentials instance and uses that to - # authenticate instead. + # Extends the base class to use a transient + # ServiceAccountJwtHeaderCredentials for certain cases. def apply! a_hash, opts = {} - # Use the base implementation if scopes are set - unless scope.nil? && target_audience.nil? + # Use a self-singed JWT if there's no information that can be used to + # obtain an OAuth token, OR if there are scopes but also an assertion + # that they are default scopes that shouldn't be used to fetch a token. + if target_audience.nil? && (scope.nil? || enable_self_signed_jwt?) + apply_self_signed_jwt! a_hash + else super - return end + end + + private + def apply_self_signed_jwt! a_hash # 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 key_io = StringIO.new MultiJson.dump(cred_json) - alt = alt_clz.make_creds json_key_io: key_io + alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io alt.apply! a_hash end end diff --git a/spec/googleauth/credentials_spec.rb b/spec/googleauth/credentials_spec.rb index 0d5c06d0..f7520860 100644 --- a/spec/googleauth/credentials_spec.rb +++ b/spec/googleauth/credentials_spec.rb @@ -46,37 +46,37 @@ } end - it "uses a default scope" do + def mock_signet 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| + yield options if block_given? + mocked_signet + end + mocked_signet + end + + it "uses a default scope" do + mock_signet do |options| expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") expect(options[:scope]).to eq([]) expect(options[:issuer]).to eq(default_keyfile_hash["client_email"]) expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA) - - mocked_signet end Google::Auth::Credentials.new default_keyfile_hash end 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| + mock_signet do |options| expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") expect(options[:scope]).to eq(["http://example.com/scope"]) expect(options[:issuer]).to eq(default_keyfile_hash["client_email"]) expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA) - - mocked_signet end Google::Auth::Credentials.new default_keyfile_hash, scope: "http://example.com/scope" @@ -246,26 +246,18 @@ class TestCredentials5 < Google::Auth::Credentials allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil } 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| + mocked_signet = mock_signet + + allow(Google::Auth).to receive(:get_application_default) do |scope, options| expect(scope).to eq([TestCredentials5::SCOPE]) + expect(options[:enable_self_signed_jwt]).to be_nil + expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") + expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") # This should really be a Signet::OAuth2::Client object, # but mocking is making that difficult, so return a valid hash instead. default_keyfile_hash end - allow(Signet::OAuth2::Client).to receive(:new) do |options| - expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") - expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") - expect(options[:scope]).to eq(["http://example.com/scope"]) - expect(options[:issuer]).to eq(default_keyfile_hash["client_email"]) - expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA) - - mocked_signet - end creds = TestCredentials5.default expect(creds).to be_a_kind_of(TestCredentials5) @@ -446,7 +438,7 @@ class TestCredentials14 < Google::Auth::Credentials expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"]) end - it "subclasses that find no matches default to Google::Auth.get_application_default" do + it "subclasses that find no matches default to Google::Auth.get_application_default with self-signed jwt enabled" do class TestCredentials15 < Google::Auth::Credentials self.scope = "http://example.com/scope" self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY] @@ -459,54 +451,116 @@ class TestCredentials15 < Google::Auth::Credentials allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil } 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| + mocked_signet = mock_signet + + allow(Google::Auth).to receive(:get_application_default) do |scope, options| expect(scope).to eq(TestCredentials15.scope) + expect(options[:enable_self_signed_jwt]).to eq(true) + expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") + expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") # This should really be a Signet::OAuth2::Client object, # but mocking is making that difficult, so return a valid hash instead. default_keyfile_hash end - allow(Signet::OAuth2::Client).to receive(:new) do |options| + + creds = TestCredentials15.default enable_self_signed_jwt: true + expect(creds).to be_a_kind_of(TestCredentials15) + expect(creds.client).to eq(mocked_signet) + expect(creds.project_id).to eq(default_keyfile_hash["project_id"]) + expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"]) + end + + it "subclasses that find no matches default to Google::Auth.get_application_default with self-signed jwt disabled" do + class TestCredentials16 < Google::Auth::Credentials + self.scope = "http://example.com/scope" + self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY] + self.paths = ["~/default/path/to/file.txt"] + end + + allow(::ENV).to receive(:[]).with("GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS") { "true" } + allow(::ENV).to receive(:[]).with("PATH_ENV_DUMMY") { "/fake/path/to/file.txt" } + allow(::File).to receive(:file?).with("/fake/path/to/file.txt") { false } + allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil } + allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false } + + mocked_signet = mock_signet + + allow(Google::Auth).to receive(:get_application_default) do |scope, options| + expect(scope).to eq(TestCredentials16.scope) + expect(options[:enable_self_signed_jwt]).to be_nil expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token") expect(options[:audience]).to eq("https://oauth2.googleapis.com/token") - expect(options[:scope]).to eq(["http://example.com/scope"]) - expect(options[:issuer]).to eq(default_keyfile_hash["client_email"]) - expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA) - mocked_signet + # This should really be a Signet::OAuth2::Client object, + # but mocking is making that difficult, so return a valid hash instead. + default_keyfile_hash end - creds = TestCredentials15.default - expect(creds).to be_a_kind_of(TestCredentials15) + creds = TestCredentials16.default + expect(creds).to be_a_kind_of(TestCredentials16) + expect(creds.client).to eq(mocked_signet) + expect(creds.project_id).to eq(default_keyfile_hash["project_id"]) + expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"]) + end + + it "subclasses that find no matches default to Google::Auth.get_application_default with custom values" do + scope2 = "http://example.com/scope2" + + class TestCredentials17 < Google::Auth::Credentials + self.scope = "http://example.com/scope" + self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY] + self.paths = ["~/default/path/to/file.txt"] + self.token_credential_uri = "https://example.com/token2" + self.audience = "https://example.com/token3" + end + + allow(::ENV).to receive(:[]).with("GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS") { "true" } + allow(::ENV).to receive(:[]).with("PATH_ENV_DUMMY") { "/fake/path/to/file.txt" } + allow(::File).to receive(:file?).with("/fake/path/to/file.txt") { false } + allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil } + allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false } + + mocked_signet = mock_signet + + allow(Google::Auth).to receive(:get_application_default) do |scope, options| + expect(scope).to eq(scope2) + expect(options[:enable_self_signed_jwt]).to eq(false) + expect(options[:token_credential_uri]).to eq("https://example.com/token2") + expect(options[:audience]).to eq("https://example.com/token3") + + # This should really be a Signet::OAuth2::Client object, + # but mocking is making that difficult, so return a valid hash instead. + default_keyfile_hash + end + + creds = TestCredentials17.default scope: scope2, enable_self_signed_jwt: true + expect(creds).to be_a_kind_of(TestCredentials17) expect(creds.client).to eq(mocked_signet) expect(creds.project_id).to eq(default_keyfile_hash["project_id"]) expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"]) end it "subclasses delegate up the class hierarchy" do - class TestCredentials16 < Google::Auth::Credentials + class TestCredentials18 < Google::Auth::Credentials self.scope = "http://example.com/scope" self.target_audience = "https://example.com/target_audience" self.env_vars = ["TEST_PATH", "TEST_JSON_VARS"] self.paths = ["~/default/path/to/file.txt"] end - class TestCredentials17 < TestCredentials16 + class TestCredentials19 < TestCredentials18 end - expect(TestCredentials17.scope).to eq(["http://example.com/scope"]) - expect(TestCredentials17.target_audience).to eq("https://example.com/target_audience") - expect(TestCredentials17.env_vars).to eq(["TEST_PATH", "TEST_JSON_VARS"]) - expect(TestCredentials17.paths).to eq(["~/default/path/to/file.txt"]) + expect(TestCredentials19.scope).to eq(["http://example.com/scope"]) + expect(TestCredentials19.target_audience).to eq("https://example.com/target_audience") + expect(TestCredentials19.env_vars).to eq(["TEST_PATH", "TEST_JSON_VARS"]) + expect(TestCredentials19.paths).to eq(["~/default/path/to/file.txt"]) - TestCredentials17.token_credential_uri = "https://example.com/token2" - expect(TestCredentials17.token_credential_uri).to eq("https://example.com/token2") - TestCredentials17.token_credential_uri = nil - expect(TestCredentials17.token_credential_uri).to eq("https://oauth2.googleapis.com/token") + TestCredentials19.token_credential_uri = "https://example.com/token2" + expect(TestCredentials19.token_credential_uri).to eq("https://example.com/token2") + TestCredentials19.token_credential_uri = nil + expect(TestCredentials19.token_credential_uri).to eq("https://oauth2.googleapis.com/token") end end diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index e7186c6a..5083b731 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -169,6 +169,14 @@ def cred_json_text it_behaves_like "jwt header auth" end + context "when enable_self_signed_jwt is set" do + before :example do + @client.instance_variable_set(:@enable_self_signed_jwt, true) + end + + it_behaves_like "jwt header auth" + end + describe "#from_env" do before :example do @var_name = ENV_VAR