File: pluggable_credentials.rb

package info (click to toggle)
ruby-googleauth 1.16.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 492 kB
  • sloc: ruby: 3,194; makefile: 4
file content (171 lines) | stat: -rw-r--r-- 8,199 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# Copyright 2023 Google, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "open3"
require "time"
require "googleauth/errors"
require "googleauth/external_account/base_credentials"
require "googleauth/external_account/external_account_utils"

module Google
  # Module Auth provides classes that provide Google-specific authorization used to access Google APIs.
  module Auth
    module ExternalAccount
      # This module handles the retrieval of credentials from Google Cloud by utilizing the any 3PI
      # provider then exchanging the credentials for a short-lived Google Cloud access token.
      class PluggableAuthCredentials
        # constant for pluggable auth enablement in environment variable.
        ENABLE_PLUGGABLE_ENV = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES".freeze
        EXECUTABLE_SUPPORTED_MAX_VERSION = 1
        EXECUTABLE_TIMEOUT_MILLIS_DEFAULT = 30 * 1000
        EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000
        EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000
        ID_TOKEN_TYPE = ["urn:ietf:params:oauth:token-type:jwt", "urn:ietf:params:oauth:token-type:id_token"].freeze

        include Google::Auth::ExternalAccount::BaseCredentials
        include Google::Auth::ExternalAccount::ExternalAccountUtils
        extend CredentialsLoader

        # Will always be nil, but method still gets used.
        attr_reader :client_id

        # Initialize from options map.
        #
        # @param [Hash] options Configuration options
        # @option options [String] :audience Audience for the token
        # @option options [Hash] :credential_source Credential source configuration that contains executable
        #   configuration
        # @raise [Google::Auth::InitializationError] If executable source, command is missing, or timeout is invalid
        def initialize options = {}
          base_setup options

          @audience = options[:audience]
          @credential_source = options[:credential_source] || {}
          @credential_source_executable = @credential_source[:executable]
          if @credential_source_executable.nil?
            raise InitializationError,
                  "Missing excutable source. An 'executable' must be provided"
          end
          @credential_source_executable_command = @credential_source_executable[:command]
          if @credential_source_executable_command.nil?
            raise InitializationError, "Missing command field. Executable command must be provided."
          end
          @credential_source_executable_timeout_millis = @credential_source_executable[:timeout_millis] ||
                                                         EXECUTABLE_TIMEOUT_MILLIS_DEFAULT
          if @credential_source_executable_timeout_millis < EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND ||
             @credential_source_executable_timeout_millis > EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND
            raise InitializationError, "Timeout must be between 5 and 120 seconds."
          end
          @credential_source_executable_output_file = @credential_source_executable[:output_file]
        end

        # Retrieves the subject token using the credential_source object.
        #
        # @return [String] The retrieved subject token
        # @raise [Google::Auth::CredentialsError] If executables are not allowed, if token retrieval fails,
        #   or if the token is invalid
        def retrieve_subject_token!
          unless ENV[ENABLE_PLUGGABLE_ENV] == "1"
            raise CredentialsError,
                  "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') " \
                  "to run."
          end
          # check output file first
          subject_token = load_subject_token_from_output_file
          return subject_token unless subject_token.nil?
          # environment variable injection
          env = inject_environment_variables
          output = subprocess_with_timeout env, @credential_source_executable_command,
                                           @credential_source_executable_timeout_millis
          response = MultiJson.load output, symbolize_keys: true
          parse_subject_token response
        end

        private

        def load_subject_token_from_output_file
          return nil if @credential_source_executable_output_file.nil?
          return nil unless File.exist? @credential_source_executable_output_file
          begin
            content = File.read @credential_source_executable_output_file, encoding: "utf-8"
            response = MultiJson.load content, symbolize_keys: true
          rescue StandardError
            return nil
          end
          begin
            subject_token = parse_subject_token response
          rescue StandardError => e
            return nil if e.message.match(/The token returned by the executable is expired/)
            raise CredentialsError, e.message
          end
          subject_token
        end

        def parse_subject_token response
          validate_response_schema response
          unless response[:success]
            if response[:code].nil? || response[:message].nil?
              raise CredentialsError, "Error code and message fields are required in the response."
            end
            raise CredentialsError,
                  "Executable returned unsuccessful response: code: #{response[:code]}, message: #{response[:message]}."
          end
          if response[:expiration_time] && response[:expiration_time] < Time.now.to_i
            raise CredentialsError, "The token returned by the executable is expired."
          end
          if response[:token_type].nil?
            raise CredentialsError,
                  "The executable response is missing the token_type field."
          end
          return response[:id_token] if ID_TOKEN_TYPE.include? response[:token_type]
          return response[:saml_response] if response[:token_type] == "urn:ietf:params:oauth:token-type:saml2"
          raise CredentialsError, "Executable returned unsupported token type."
        end

        def validate_response_schema response
          raise CredentialsError, "The executable response is missing the version field." if response[:version].nil?
          if response[:version] > EXECUTABLE_SUPPORTED_MAX_VERSION
            raise CredentialsError, "Executable returned unsupported version #{response[:version]}."
          end
          raise CredentialsError, "The executable response is missing the success field." if response[:success].nil?
        end

        def inject_environment_variables
          env = ENV.to_h
          env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = @audience
          env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = @subject_token_type
          env["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "0" # only non-interactive mode we support.
          unless @service_account_impersonation_url.nil?
            env["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = service_account_email
          end
          unless @credential_source_executable_output_file.nil?
            env["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = @credential_source_executable_output_file
          end
          env
        end

        def subprocess_with_timeout environment_vars, command, timeout_seconds
          Timeout.timeout timeout_seconds do
            output, error, status = Open3.capture3 environment_vars, command
            unless status.success?
              raise CredentialsError,
                    "Executable exited with non-zero return code #{status.exitstatus}. Error: #{output}, #{error}"
            end
            output
          end
        end
      end
    end
  end
end