diff --git a/.toys/linkinator.rb b/.toys/linkinator.rb index 90fd7a34..66b06c0c 100644 --- a/.toys/linkinator.rb +++ b/.toys/linkinator.rb @@ -32,7 +32,7 @@ def run end def check_links - result = exec ["npx", "linkinator", "./doc"], out: :capture + result = exec ["npx", "linkinator", "./doc", "--skip", "stackoverflow.com"], out: :capture puts result.captured_out checked_links = result.captured_out.split "\n" checked_links.select! { |link| link =~ /^\[(\d+)\]/ && ::Regexp.last_match[1] != "200" } diff --git a/Credentials.md b/Credentials.md index 32bba7ec..1b3bd4be 100644 --- a/Credentials.md +++ b/Credentials.md @@ -66,7 +66,7 @@ that exposes common initialization functionality, such as creating credentials f - Allows a GCP principal identified by a set of source credentials to impersonate a service account - Useful for delegation of authority and managing permissions across service accounts - Source credentials must have the Service Account Token Creator role on the target - - This credential type does not have a supported JSON form + - This credential type supports JSON configuration. The JSON form of this credential type has a `"type"` field with the value `"impersonated_service_account"`. ## User Authentication diff --git a/README.md b/README.md index 8c72f209..2df56387 100644 --- a/README.md +++ b/README.md @@ -292,5 +292,4 @@ hesitate to about the client or APIs on [StackOverflow](http://stackoverflow.com). [application default credentials]: https://cloud.google.com/docs/authentication/provide-credentials-adc -[contributing]: https://github.com/googleapis/google-auth-library-ruby/tree/main/.github/CONTRIBUTING.md [license]: https://github.com/googleapis/google-auth-library-ruby/tree/main/LICENSE diff --git a/lib/googleauth/default_credentials.rb b/lib/googleauth/default_credentials.rb index efb03b77..ce722c46 100644 --- a/lib/googleauth/default_credentials.rb +++ b/lib/googleauth/default_credentials.rb @@ -21,6 +21,7 @@ require "googleauth/service_account" require "googleauth/service_account_jwt_header" require "googleauth/user_refresh" +require "googleauth/impersonated_service_account" module Google # Module Auth provides classes that provide Google-specific authorization @@ -114,6 +115,8 @@ def self.determine_creds_class json_key_io = nil UserRefreshCredentials when ExternalAccount::Credentials::CREDENTIAL_TYPE_NAME ExternalAccount::Credentials + when ImpersonatedServiceAccountCredentials::CREDENTIAL_TYPE_NAME + ImpersonatedServiceAccountCredentials else raise InitializationError, "credentials type '#{type}' is not supported" end diff --git a/lib/googleauth/impersonated_service_account.rb b/lib/googleauth/impersonated_service_account.rb index bc4b8538..c5031ffb 100644 --- a/lib/googleauth/impersonated_service_account.rb +++ b/lib/googleauth/impersonated_service_account.rb @@ -23,6 +23,9 @@ module Auth # and then that claim is exchanged for a short-lived token at an IAMCredentials endpoint. # The short-lived token and its expiration time are cached. class ImpersonatedServiceAccountCredentials + # @private + CREDENTIAL_TYPE_NAME = "impersonated_service_account".freeze + # @private ERROR_SUFFIX = <<~ERROR.freeze when trying to get security access token @@ -84,11 +87,50 @@ class ImpersonatedServiceAccountCredentials # defining the permissions required for the token. # @option options [Object] :source_credentials The authenticated principal that will be used # to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials. + # @option options [IO] :json_key_io The IO object that contains the credential configuration. + # It is exclusive with `:base_credentials` and `:source_credentials` options. # # @return [Google::Auth::ImpersonatedServiceAccountCredentials] def self.make_creds options = {} - new options + if options[:json_key_io] + make_creds_from_json options + else + new options + end + end + + # @private + def self.make_creds_from_json options + json_key_io = options[:json_key_io] + if options[:base_credentials] || options[:source_credentials] + raise Google::Auth::InitializationError, + "json_key_io is not compatible with base_credentials or source_credentials" + end + + require "googleauth/default_credentials" + impersonated_json = MultiJson.load json_key_io.read + source_credentials_info = impersonated_json["source_credentials"] + + if source_credentials_info["type"] == CREDENTIAL_TYPE_NAME + raise Google::Auth::InitializationError, + "Source credentials can't be of type impersonated_service_account, " \ + "use delegates to chain impersonation." + end + + source_credentials = DefaultCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(source_credentials_info)) + ) + + impersonation_url = impersonated_json["service_account_impersonation_url"] + scope = options[:scope] || impersonated_json["scopes"] + + new( + source_credentials: source_credentials, + impersonation_url: impersonation_url, + scope: scope + ) end + private_class_method :make_creds_from_json # Initializes a new instance of ImpersonatedServiceAccountCredentials. # @@ -105,6 +147,7 @@ def self.make_creds options = {} # - `{source_sa_email}` is the email address of the service account to impersonate. # @option options [Array, String] :scope (required) The scope(s) for the short-lived impersonation token, # defining the permissions required for the token. + # It will override the scope from the `json_key_io` file if provided. # @option options [Object] :source_credentials The authenticated principal that will be used # to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials. # It is redundant to provide both source and base credentials as only source will be used, diff --git a/spec/googleauth/get_application_default_spec.rb b/spec/googleauth/get_application_default_spec.rb index 87280815..213f8df6 100644 --- a/spec/googleauth/get_application_default_spec.rb +++ b/spec/googleauth/get_application_default_spec.rb @@ -229,6 +229,49 @@ def cred_json_text it_behaves_like "it cannot load misconfigured credentials" end + describe "when credential type is impersonated_service_account" do + let :cred_json do + { + "type": "impersonated_service_account", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-account@example.com:generateAccessToken", + "source_credentials": { + "type": "authorized_user", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "scope": "https://www.googleapis.com/auth/iam" + } + } + end + + it "succeeds if 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, MultiJson.dump(cred_json) + ENV[@var_name] = key_path + creds = Google::Auth.get_application_default @scope, options + expect(creds).to be_a(Google::Auth::ImpersonatedServiceAccountCredentials) + end + end + + it "fails if the GOOGLE_APPLICATION_CREDENTIALS path does not exist" do + Dir.mktmpdir do |dir| + key_path = File.join dir, "does-not-exist" + ENV[@var_name] = key_path + begin + Google::Auth.get_application_default @scope, options + fail "Expected to raise error" + rescue => e + expect(e).to be_a Google::Auth::InitializationError + expect(e).to be_a Google::Auth::Error + expect(e.message).to include "Unable to read the credential file" + expect(e.message).to include "does-not-exist" + end + end + end + end + describe "when credential type is unknown" do let :cred_json do { diff --git a/spec/googleauth/impersonated_service_account_spec.rb b/spec/googleauth/impersonated_service_account_spec.rb index aa81f8db..9a8a8144 100644 --- a/spec/googleauth/impersonated_service_account_spec.rb +++ b/spec/googleauth/impersonated_service_account_spec.rb @@ -17,6 +17,107 @@ describe Google::Auth::ImpersonatedServiceAccountCredentials do + describe ".make_creds with json_key_io" do + let(:key) { OpenSSL::PKey::RSA.new 2048 } + + let(:authorized_user_json) do + { + "type": "authorized_user", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token" + } + end + + let(:service_account_json) do + { + "type": "service_account", + "private_key": key.to_pem, + "client_email": "client_email", + "scope": "https://www.googleapis.com/auth/iam" + } + end + + let(:impersonated_json) do + { + "type": "impersonated_service_account", + "service_account_impersonation_url": impersonation_url, + "scopes": ["scope1"], + "source_credentials": source_credentials_json + } + end + + context "with authorized_user source credentials" do + let(:source_credentials_json) { authorized_user_json } + + it "creates credentials with UserRefreshCredentials as source" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(impersonated_json)) + ) + + expect(creds).to be_a(Google::Auth::ImpersonatedServiceAccountCredentials) + expect(creds.source_credentials).to be_a(Google::Auth::UserRefreshCredentials) + expect(creds.impersonation_url).to eq(impersonation_url) + expect(creds.scope).to eq(["scope1"]) + end + end + + context "with service_account source credentials" do + let(:source_credentials_json) { service_account_json } + + it "creates credentials with ServiceAccountCredentials as source" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(impersonated_json)) + ) + + expect(creds).to be_a(Google::Auth::ImpersonatedServiceAccountCredentials) + expect(creds.source_credentials).to be_a(Google::Auth::ServiceAccountCredentials) + expect(creds.impersonation_url).to eq(impersonation_url) + expect(creds.scope).to eq(["scope1"]) + end + end + + context "with recursive impersonated_service_account source credentials" do + let(:recursive_impersonated_json) do + { + "type": "impersonated_service_account", + "service_account_impersonation_url": impersonation_url, + "scopes": ["scope1"], + "source_credentials": { + "type": "impersonated_service_account" + } + } + end + + it "raises a runtime error" do + expect { + Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(recursive_impersonated_json)) + ) + }.to raise_error(Google::Auth::InitializationError, /Source credentials can't be of type.*use delegates/) + end + end + + context "scope handling" do + let(:source_credentials_json) { authorized_user_json } + + it "uses scope from JSON if not provided in options" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(impersonated_json)) + ) + expect(creds.scope).to eq(["scope1"]) + end + + it "uses scope from options if provided" do + creds = Google::Auth::ImpersonatedServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(MultiJson.dump(impersonated_json)), + scope: ["scope2"] + ) + expect(creds.scope).to eq(["scope2"]) + end + end + end + let(:impersonation_url) {"https://iamcredentials.example.com/v1/projects/-/serviceAccounts/test:generateAccessToken"} def make_auth_stubs opts