From a7ff57b2b0832cfa62b9e86fb82bb3c1e03e93bb Mon Sep 17 00:00:00 2001 From: Daniel Azuma Date: Thu, 7 Dec 2023 13:51:57 -0800 Subject: [PATCH] feat: Include universe_domain in credentials (#460) --- lib/googleauth/compute_engine.rb | 18 +-- lib/googleauth/credentials.rb | 19 ++- lib/googleauth/external_account.rb | 3 +- .../external_account/base_credentials.rb | 2 + lib/googleauth/json_key_reader.rb | 3 +- lib/googleauth/service_account.rb | 16 ++- lib/googleauth/signet.rb | 12 ++ lib/googleauth/user_refresh.rb | 6 +- spec/googleauth/compute_engine_spec.rb | 56 ++++++++- spec/googleauth/external_account_spec.rb | 108 ++++++++++++++++++ spec/googleauth/service_account_spec.rb | 37 +++++- spec/googleauth/user_refresh_spec.rb | 23 ++++ 12 files changed, 277 insertions(+), 26 deletions(-) diff --git a/lib/googleauth/compute_engine.rb b/lib/googleauth/compute_engine.rb index eb9370bb..441610e9 100644 --- a/lib/googleauth/compute_engine.rb +++ b/lib/googleauth/compute_engine.rb @@ -83,7 +83,7 @@ def reset_cache # Overrides the super class method to change how access tokens are # fetched. def fetch_access_token _options = {} - if target_audience + if token_type == :id_token query = { "audience" => target_audience, "format" => "full" } entry = "service-accounts/default/identity" else @@ -113,12 +113,16 @@ def fetch_access_token _options = {} private def build_token_hash body, content_type - if ["text/html", "application/text"].include? content_type - key = target_audience ? "id_token" : "access_token" - { key => body } - else - Signet::OAuth2.parse_credentials body, content_type - end + hash = + if ["text/html", "application/text"].include? content_type + { token_type.to_s => body } + else + Signet::OAuth2.parse_credentials body, content_type + end + universe_domain = Google::Cloud.env.lookup_metadata "universe", "universe_domain" + universe_domain = "googleapis.com" if !universe_domain || universe_domain.empty? + hash["universe_domain"] = universe_domain.strip + hash end end end diff --git a/lib/googleauth/credentials.rb b/lib/googleauth/credentials.rb index c3eb5fce..0eea6c58 100644 --- a/lib/googleauth/credentials.rb +++ b/lib/googleauth/credentials.rb @@ -259,7 +259,7 @@ def self.paths= new_paths # @return [Object] The value # def self.lookup_auth_param name, method_name = name - val = instance_variable_get "@#{name}".to_sym + val = instance_variable_get :"@#{name}" val = yield if val.nil? && block_given? return val unless val.nil? return superclass.send method_name if superclass.respond_to? method_name @@ -328,9 +328,13 @@ def self.lookup_local_constant name # @return [Proc] Returns a reference to the {Signet::OAuth2::Client#apply} method, # suitable for passing as a closure. # + # @!attribute [rw] universe_domain + # @return [String] The universe domain issuing these credentials. + # def_delegators :@client, :token_credential_uri, :audience, - :scope, :issuer, :signing_key, :updater_proc, :target_audience + :scope, :issuer, :signing_key, :updater_proc, :target_audience, + :universe_domain, :universe_domain= ## # Creates a new Credentials instance with the provided auth credentials, and with the default @@ -506,12 +510,15 @@ def client_options options needs_scope = options["target_audience"].nil? # client options for initializing signet client - { token_credential_uri: options["token_credential_uri"], + { + token_credential_uri: options["token_credential_uri"], audience: options["audience"], scope: (needs_scope ? Array(options["scope"]) : nil), target_audience: options["target_audience"], issuer: options["client_email"], - signing_key: OpenSSL::PKey::RSA.new(options["private_key"]) } + signing_key: OpenSSL::PKey::RSA.new(options["private_key"]), + universe_domain: options["universe_domain"] || "googleapis.com" + } end # rubocop:enable Metrics/AbcSize @@ -526,7 +533,7 @@ def update_from_hash hash, options hash = stringify_hash_keys hash hash["scope"] ||= options[:scope] hash["target_audience"] ||= options[:target_audience] - @project_id ||= (hash["project_id"] || hash["project"]) + @project_id ||= hash["project_id"] || hash["project"] @quota_project_id ||= hash["quota_project_id"] @client = init_client hash, options end @@ -536,7 +543,7 @@ def update_from_filepath path, options json = JSON.parse ::File.read(path) json["scope"] ||= options[:scope] json["target_audience"] ||= options[:target_audience] - @project_id ||= (json["project_id"] || json["project"]) + @project_id ||= json["project_id"] || json["project"] @quota_project_id ||= json["quota_project_id"] @client = init_client json, options end diff --git a/lib/googleauth/external_account.rb b/lib/googleauth/external_account.rb index ad5abd7c..a610ed15 100644 --- a/lib/googleauth/external_account.rb +++ b/lib/googleauth/external_account.rb @@ -73,7 +73,8 @@ def make_aws_credentials user_creds, scope subject_token_type: user_creds[:subject_token_type], token_url: user_creds[:token_url], credential_source: user_creds[:credential_source], - service_account_impersonation_url: user_creds[:service_account_impersonation_url] + service_account_impersonation_url: user_creds[:service_account_impersonation_url], + universe_domain: user_creds[:universe_domain] ) end diff --git a/lib/googleauth/external_account/base_credentials.rb b/lib/googleauth/external_account/base_credentials.rb index db33b302..a1c0ffe2 100644 --- a/lib/googleauth/external_account/base_credentials.rb +++ b/lib/googleauth/external_account/base_credentials.rb @@ -42,6 +42,7 @@ module BaseCredentials attr_reader :expires_at attr_accessor :access_token + attr_accessor :universe_domain def expires_within? seconds # This method is needed for BaseClient @@ -110,6 +111,7 @@ def base_setup options @quota_project_id = options[:quota_project_id] @project_id = nil @workforce_pool_user_project = options[:workforce_pool_user_project] + @universe_domain = options[:universe_domain] || "googleapis.com" @expires_at = nil @access_token = nil diff --git a/lib/googleauth/json_key_reader.rb b/lib/googleauth/json_key_reader.rb index 5cb2542c..07077bc8 100644 --- a/lib/googleauth/json_key_reader.rb +++ b/lib/googleauth/json_key_reader.rb @@ -27,7 +27,8 @@ def read_json_key json_key_io json_key["private_key"], json_key["client_email"], json_key["project_id"], - json_key["quota_project_id"] + json_key["quota_project_id"], + json_key["universe_domain"] ] end end diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 61e764a8..50ce43cf 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -53,12 +53,13 @@ def self.make_creds options = {} raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience if json_key_io - private_key, client_email, project_id, quota_project_id = read_json_key json_key_io + private_key, client_email, project_id, quota_project_id, universe_domain = read_json_key json_key_io else private_key = unescape ENV[CredentialsLoader::PRIVATE_KEY_VAR] client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] project_id = ENV[CredentialsLoader::PROJECT_ID_VAR] quota_project_id = nil + universe_domain = nil end project_id ||= CredentialsLoader.load_gcloud_project_id @@ -70,7 +71,8 @@ def self.make_creds options = {} issuer: client_email, signing_key: OpenSSL::PKey::RSA.new(private_key), project_id: project_id, - quota_project_id: quota_project_id) + quota_project_id: quota_project_id, + universe_domain: universe_domain || "googleapis.com") .configure_connection(options) end @@ -95,8 +97,9 @@ def initialize options = {} def apply! a_hash, opts = {} # 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?) + # that they are default scopes that shouldn't be used to fetch a token, + # OR we are not in the default universe and thus OAuth isn't supported. + if target_audience.nil? && (scope.nil? || enable_self_signed_jwt? || universe_domain != "googleapis.com") apply_self_signed_jwt! a_hash else super @@ -138,6 +141,7 @@ class ServiceAccountJwtHeaderCredentials extend JsonKeyReader attr_reader :project_id attr_reader :quota_project_id + attr_accessor :universe_domain # Create a ServiceAccountJwtHeaderCredentials. # @@ -154,14 +158,16 @@ def self.make_creds options = {} def initialize options = {} json_key_io = options[:json_key_io] if json_key_io - @private_key, @issuer, @project_id, @quota_project_id = + @private_key, @issuer, @project_id, @quota_project_id, @universe_domain = self.class.read_json_key json_key_io else @private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR] @issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR] @project_id = ENV[CredentialsLoader::PROJECT_ID_VAR] @quota_project_id = nil + @universe_domain = nil end + @universe_domain ||= "googleapis.com" @project_id ||= CredentialsLoader.load_gcloud_project_id @signing_key = OpenSSL::PKey::RSA.new @private_key @scope = options[:scope] diff --git a/lib/googleauth/signet.rb b/lib/googleauth/signet.rb index 4c5fcc09..7de875e8 100644 --- a/lib/googleauth/signet.rb +++ b/lib/googleauth/signet.rb @@ -25,6 +25,15 @@ module OAuth2 class Client include Google::Auth::BaseClient + alias update_token_signet_base update_token! + + def update_token! options = {} + options = deep_hash_normalize options + update_token_signet_base options + self.universe_domain = options[:universe_domain] if options.key? :universe_domain + self + end + def configure_connection options @connection_info = options[:connection_builder] || options[:default_connection] @@ -36,6 +45,9 @@ def token_type target_audience ? :id_token : :access_token end + # Set the universe domain + attr_accessor :universe_domain + alias orig_fetch_access_token! fetch_access_token! def fetch_access_token! options = {} unless options[:connection] diff --git a/lib/googleauth/user_refresh.rb b/lib/googleauth/user_refresh.rb index 64528ab3..0ccc9e13 100644 --- a/lib/googleauth/user_refresh.rb +++ b/lib/googleauth/user_refresh.rb @@ -50,7 +50,8 @@ def self.make_creds options = {} "client_secret" => ENV[CredentialsLoader::CLIENT_SECRET_VAR], "refresh_token" => ENV[CredentialsLoader::REFRESH_TOKEN_VAR], "project_id" => ENV[CredentialsLoader::PROJECT_ID_VAR], - "quota_project_id" => nil + "quota_project_id" => nil, + "universe_domain" => nil } new(token_credential_uri: TOKEN_CRED_URI, client_id: user_creds["client_id"], @@ -58,7 +59,8 @@ def self.make_creds options = {} refresh_token: user_creds["refresh_token"], project_id: user_creds["project_id"], quota_project_id: user_creds["quota_project_id"], - scope: scope) + scope: scope, + universe_domain: user_creds["universe_domain"] || "googleapis.com") .configure_connection(options) end diff --git a/spec/googleauth/compute_engine_spec.rb b/spec/googleauth/compute_engine_spec.rb index 8c5fa441..324937de 100644 --- a/spec/googleauth/compute_engine_spec.rb +++ b/spec/googleauth/compute_engine_spec.rb @@ -38,6 +38,15 @@ end def make_auth_stubs opts + universe_stub = stub_request(:get, "http://169.254.169.254/computeMetadata/v1/universe/universe_domain") + .with(headers: { "Metadata-Flavor" => "Google" }) + if !defined?(@universe_domain) || !@universe_domain + universe_stub.to_return body: "", status: 404, headers: {"Metadata-Flavor" => "Google" } + elsif @universe_domain.is_a? Class + universe_stub.to_raise @universe_domain + else + universe_stub.to_return body: @universe_domain, status: 200, headers: {"Metadata-Flavor" => "Google" } + end if opts[:access_token] body = MultiJson.dump("access_token" => opts[:access_token], "token_type" => "Bearer", @@ -50,17 +59,58 @@ def make_auth_stubs opts .with(headers: { "Metadata-Flavor" => "Google" }) .to_return(body: body, status: 200, - headers: { "Content-Type" => "application/json" }) + headers: { "Content-Type" => "application/json", "Metadata-Flavor" => "Google" }) elsif opts[:id_token] stub_request(:get, MD_ID_URI) .with(headers: { "Metadata-Flavor" => "Google" }) .to_return(body: opts[:id_token], status: 200, - headers: { "Content-Type" => "text/html" }) + headers: { "Content-Type" => "text/html", "Metadata-Flavor" => "Google" }) + end + end + + context "default universe" do + it_behaves_like "apply/apply! are OK" + + it "sets the universe" do + make_auth_stubs access_token: "1/abcde" + @client.fetch_access_token! + expect(@client.universe_domain).to eq("googleapis.com") end end - it_behaves_like "apply/apply! are OK" + context "custom universe" do + before :example do + @universe_domain = "myuniverse.com" + end + + it_behaves_like "apply/apply! are OK" + + it "sets the universe" do + make_auth_stubs access_token: "1/abcde" + @client.fetch_access_token! + expect(@client.universe_domain).to eq("myuniverse.com") + end + + it "supports updating the universe_domain" do + make_auth_stubs access_token: "1/abcde" + @client.fetch_access_token! + @client.universe_domain = "anotheruniverse.com" + expect(@client.universe_domain).to eq("anotheruniverse.com") + end + end + + context "error in universe_domain" do + before :example do + @universe_domain = Errno::EHOSTDOWN + end + + it "results in an error" do + make_auth_stubs access_token: "1/abcde" + expect { @client.fetch_access_token! } + .to raise_error Signet::AuthorizationError + end + end context "metadata is unavailable" do describe "#fetch_access_token" do diff --git a/spec/googleauth/external_account_spec.rb b/spec/googleauth/external_account_spec.rb index f6e1ef60..676aec1e 100644 --- a/spec/googleauth/external_account_spec.rb +++ b/spec/googleauth/external_account_spec.rb @@ -20,6 +20,114 @@ describe Google::Auth::ExternalAccount::Credentials do + describe "universe_domain checks", :focus do + before :example do + @tempfile = Tempfile.new("aws") + end + + after :example do + @tempfile.close + @tempfile.unlink + end + + def load_file options + @tempfile.write(MultiJson.dump(options)) + @tempfile.rewind + Google::Auth::ExternalAccount::Credentials.make_creds(json_key_io: @tempfile) + end + + it "loads aws without custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + 'environment_id' => 'aws1', + 'region_url' => 'http://169.254.169.254/latest/meta-data/placement/availability-zone', + 'url' => 'http://169.254.169.254/latest/meta-data/iam/security-credentials', + 'regional_cred_verification_url' => 'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15', + } + }) + expect(creds.universe_domain).to eq("googleapis.com") + end + + it "loads aws with custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + 'environment_id' => 'aws1', + 'region_url' => 'http://169.254.169.254/latest/meta-data/placement/availability-zone', + 'url' => 'http://169.254.169.254/latest/meta-data/iam/security-credentials', + 'regional_cred_verification_url' => 'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15', + }, + universe_domain: "myuniverse.com" + }) + expect(creds.universe_domain).to eq("myuniverse.com") + end + + it "loads identity pool without custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + 'file' => 'external_suject_token.txt' + } + }) + expect(creds.universe_domain).to eq("googleapis.com") + end + + it "loads identity pool with custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + 'file' => 'external_suject_token.txt' + }, + universe_domain: "myuniverse.com" + }) + expect(creds.universe_domain).to eq("myuniverse.com") + end + + it "loads pluggable without custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + executable: { + command: 'dummy_command', + }, + } + }) + expect(creds.universe_domain).to eq("googleapis.com") + end + + it "loads pluggable with custom domain" do + creds = load_file({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: { + executable: { + command: 'dummy_command', + }, + }, + universe_domain: "myuniverse.com" + }) + expect(creds.universe_domain).to eq("myuniverse.com") + end + end + describe :make_creds do it 'should be able to make aws credentials' do f = Tempfile.new('aws') diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 72037ccb..9fc375cd 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -95,7 +95,6 @@ def expect_is_encoded_jwt hdr end end - describe Google::Auth::ServiceAccountCredentials do ServiceAccountCredentials = Google::Auth::ServiceAccountCredentials let(:client_email) { "app@developer.gserviceaccount.com" } @@ -110,6 +109,10 @@ def expect_is_encoded_jwt hdr quota_project_id: "b_project_id" } end + let :cred_json_with_universe_domain do + universe_data = { universe_domain: "myuniverse.com" } + cred_json.merge universe_data + end before :example do @key = OpenSSL::PKey::RSA.new 2048 @@ -117,6 +120,10 @@ def expect_is_encoded_jwt hdr json_key_io: StringIO.new(cred_json_text), scope: "https://www.googleapis.com/auth/userinfo.profile" ) + @non_gdu_client = ServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(cred_json_text_with_universe_domain), + scope: "https://www.googleapis.com/auth/userinfo.profile" + ) @id_client = ServiceAccountCredentials.make_creds( json_key_io: StringIO.new(cred_json_text), target_audience: "https://pubsub.googleapis.com/" @@ -148,8 +155,27 @@ def cred_json_text MultiJson.dump cred_json end + def cred_json_text_with_universe_domain + MultiJson.dump cred_json_with_universe_domain + end + it_behaves_like "apply/apply! are OK" + describe "universe_domain" do + it "defaults to googleapis" do + expect(@client.universe_domain).to eq("googleapis.com") + end + + it "reads a custom domain" do + expect(@non_gdu_client.universe_domain).to eq("myuniverse.com") + end + + it "supports setting the universe_domain" do + @client.universe_domain = "myuniverse.com" + expect(@client.universe_domain).to eq("myuniverse.com") + end + end + context "when scope is nil" do before :example do @client.scope = nil @@ -176,6 +202,15 @@ def cred_json_text it_behaves_like "jwt header auth", nil end + context "when the universe domain is not google default" do + before :example do + @client.universe_domain = "myuniverse.com" + @client.scope = ['scope/1', 'scope/2'] + end + + it_behaves_like "jwt header auth", nil + end + describe "#from_env" do before :example do @var_name = ENV_VAR diff --git a/spec/googleauth/user_refresh_spec.rb b/spec/googleauth/user_refresh_spec.rb index a234eeb0..dc898fe4 100644 --- a/spec/googleauth/user_refresh_spec.rb +++ b/spec/googleauth/user_refresh_spec.rb @@ -41,6 +41,10 @@ quota_project_id: "test_project" } end + let :cred_json_with_universe_domain do + universe_data = { universe_domain: "myuniverse.com" } + cred_json.merge universe_data + end before :example do @key = OpenSSL::PKey::RSA.new 2048 @@ -48,6 +52,10 @@ json_key_io: StringIO.new(cred_json_text), scope: "https://www.googleapis.com/auth/userinfo.profile" ) + @non_gdu_client = UserRefreshCredentials.make_creds( + json_key_io: StringIO.new(cred_json_text_with_universe_domain), + scope: "https://www.googleapis.com/auth/userinfo.profile" + ) end def make_auth_stubs opts @@ -67,6 +75,11 @@ def cred_json_text missing = nil MultiJson.dump cred_json end + def cred_json_text_with_universe_domain missing = nil + cred_json_with_universe_domain.delete missing.to_sym unless missing.nil? + MultiJson.dump cred_json_with_universe_domain + end + it_behaves_like "apply/apply! are OK" describe "#from_env" do @@ -264,6 +277,16 @@ def cred_json_text missing = nil end end + describe "#universe_domain" do + it "loads the default domain" do + expect(@client.universe_domain).to eq("googleapis.com") + end + + it "loads a custom domain" do + expect(@non_gdu_client.universe_domain).to eq("myuniverse.com") + end + end + shared_examples "revoked token" do it "should nil the refresh token" do expect(@client.refresh_token).to be_nil