googleauth 1.12.2 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,9 +16,10 @@ require "multi_json"
16
16
  require "stringio"
17
17
 
18
18
  require "googleauth/credentials_loader"
19
+ require "googleauth/external_account"
19
20
  require "googleauth/service_account"
21
+ require "googleauth/service_account_jwt_header"
20
22
  require "googleauth/user_refresh"
21
- require "googleauth/external_account"
22
23
 
23
24
  module Google
24
25
  # Module Auth provides classes that provide Google-specific authorization
@@ -29,8 +30,18 @@ module Google
29
30
  class DefaultCredentials
30
31
  extend CredentialsLoader
31
32
 
32
- # override CredentialsLoader#make_creds to use the class determined by
33
+ ##
34
+ # Override CredentialsLoader#make_creds to use the class determined by
33
35
  # loading the json.
36
+ #
37
+ # **Important:** If you accept a credential configuration (credential
38
+ # JSON/File/Stream) from an external source for authentication to Google
39
+ # Cloud, you must validate it before providing it to any Google API or
40
+ # library. Providing an unvalidated credential configuration to Google
41
+ # APIs can compromise the security of your systems and data. For more
42
+ # information, refer to [Validate credential configurations from external
43
+ # sources](https://cloud.google.com/docs/authentication/external/externally-sourced-credentials).
44
+ #
34
45
  def self.make_creds options = {}
35
46
  json_key_io = options[:json_key_io]
36
47
  if json_key_io
@@ -24,7 +24,13 @@ module Google
24
24
  module Connection
25
25
  module_function
26
26
 
27
- attr_accessor :default_connection
27
+ def default_connection
28
+ @default_connection
29
+ end
30
+
31
+ def default_connection= conn
32
+ @default_connection = conn
33
+ end
28
34
 
29
35
  def connection
30
36
  @default_connection || Faraday.default_connection
@@ -0,0 +1,282 @@
1
+ # Copyright 2024 Google, Inc.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require "googleauth/signet"
16
+ require "googleauth/base_client"
17
+ require "googleauth/helpers/connection"
18
+
19
+ module Google
20
+ module Auth
21
+ # Authenticates requests using impersonation from base credentials.
22
+ # This is a two-step process: first authentication claim from the base credentials is created
23
+ # and then that claim is exchanged for a short-lived token at an IAMCredentials endpoint.
24
+ # The short-lived token and its expiration time are cached.
25
+ class ImpersonatedServiceAccountCredentials
26
+ # @private
27
+ ERROR_SUFFIX = <<~ERROR.freeze
28
+ when trying to get security access token
29
+ from IAM Credentials endpoint using the credentials provided.
30
+ ERROR
31
+
32
+ # @private
33
+ IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze
34
+
35
+ # BaseClient most importantly implements the `:updater_proc` getter,
36
+ # that returns a reference to an `apply!` method that updates
37
+ # a hash argument provided with the authorization header containing
38
+ # the access token (impersonation token in this case).
39
+ include Google::Auth::BaseClient
40
+
41
+ include Helpers::Connection
42
+
43
+ # @return [Object] The original authenticated credentials used to fetch short-lived impersonation access tokens
44
+ attr_reader :base_credentials
45
+
46
+ # @return [Object] The modified version of base credentials, tailored for impersonation purposes
47
+ # with necessary scope adjustments
48
+ attr_reader :source_credentials
49
+
50
+ # @return [String] The URL endpoint used to generate an impersonation token. This URL should follow a specific
51
+ # format to specify the impersonated service account.
52
+ attr_reader :impersonation_url
53
+
54
+ # @return [Array<String>, String] The scope(s) required for the impersonated access token,
55
+ # indicating the permissions needed for the short-lived token
56
+ attr_reader :scope
57
+
58
+ # @return [String, nil] The short-lived impersonation access token, retrieved and cached
59
+ # after making the impersonation request
60
+ attr_reader :access_token
61
+
62
+ # @return [Time, nil] The expiration time of the current access token, used to determine
63
+ # if the token is still valid
64
+ attr_reader :expires_at
65
+
66
+ # Create a ImpersonatedServiceAccountCredentials
67
+ # When you use service account impersonation, you start with an authenticated principal
68
+ # (e.g. your user account or a service account)
69
+ # and request short-lived credentials for a service account
70
+ # that has the authorization that your use case requires.
71
+ #
72
+ # @param options [Hash] A hash of options to configure the credentials.
73
+ # @option options [Object] :base_credentials (required) The authenticated principal.
74
+ # It will be used as following:
75
+ # * will be duplicated (with IAM scope) to create the source credentials if it supports duplication
76
+ # * as source credentials otherwise.
77
+ # @option options [String] :impersonation_url (required) The URL to impersonate the service account.
78
+ # This URL should follow the format:
79
+ # `https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{source_sa_email}:generateAccessToken`,
80
+ # where:
81
+ # - `{universe_domain}` is the domain of the IAMCredentials API endpoint (e.g., `googleapis.com`).
82
+ # - `{source_sa_email}` is the email address of the service account to impersonate.
83
+ # @option options [Array<String>, String] :scope (required) The scope(s) for the short-lived impersonation token,
84
+ # defining the permissions required for the token.
85
+ # @option options [Object] :source_credentials The authenticated principal that will be used
86
+ # to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials.
87
+ #
88
+ # @return [Google::Auth::ImpersonatedServiceAccountCredentials]
89
+ def self.make_creds options = {}
90
+ new options
91
+ end
92
+
93
+ # Initializes a new instance of ImpersonatedServiceAccountCredentials.
94
+ #
95
+ # @param options [Hash] A hash of options to configure the credentials.
96
+ # @option options [Object] :base_credentials (required) The authenticated principal.
97
+ # It will be used as following:
98
+ # * will be duplicated (with IAM scope) to create the source credentials if it supports duplication
99
+ # * as source credentials otherwise.
100
+ # @option options [String] :impersonation_url (required) The URL to impersonate the service account.
101
+ # This URL should follow the format:
102
+ # `https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{source_sa_email}:generateAccessToken`,
103
+ # where:
104
+ # - `{universe_domain}` is the domain of the IAMCredentials API endpoint (e.g., `googleapis.com`).
105
+ # - `{source_sa_email}` is the email address of the service account to impersonate.
106
+ # @option options [Array<String>, String] :scope (required) The scope(s) for the short-lived impersonation token,
107
+ # defining the permissions required for the token.
108
+ # @option options [Object] :source_credentials The authenticated principal that will be used
109
+ # to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials.
110
+ # It is redundant to provide both source and base credentials as only source will be used,
111
+ # but it can be done, e.g. when duplicating existing credentials.
112
+ #
113
+ # @raise [ArgumentError] If any of the required options are missing.
114
+ #
115
+ # @return [Google::Auth::ImpersonatedServiceAccountCredentials]
116
+ def initialize options = {}
117
+ @base_credentials, @impersonation_url, @scope =
118
+ options.values_at :base_credentials,
119
+ :impersonation_url,
120
+ :scope
121
+
122
+ # Fail-fast checks for required parameters
123
+ if @base_credentials.nil? && !options.key?(:source_credentials)
124
+ raise ArgumentError, "Missing required option: either :base_credentials or :source_credentials"
125
+ end
126
+ raise ArgumentError, "Missing required option: :impersonation_url" if @impersonation_url.nil?
127
+ raise ArgumentError, "Missing required option: :scope" if @scope.nil?
128
+
129
+ # Some credentials (all Signet-based ones and this one) include scope and a bunch of transient state
130
+ # (e.g. refresh status) as part of themselves
131
+ # so a copy needs to be created with the scope overriden and transient state dropped.
132
+ #
133
+ # If a credentials does not support `duplicate` we'll try to use it as is assuming it has a broad enough scope.
134
+ # This might result in an "access denied" error downstream when the token from that credentials is being used
135
+ # for the token exchange.
136
+ @source_credentials = if options.key? :source_credentials
137
+ options[:source_credentials]
138
+ elsif @base_credentials.respond_to? :duplicate
139
+ @base_credentials.duplicate({
140
+ scope: IAM_SCOPE
141
+ })
142
+ else
143
+ @base_credentials
144
+ end
145
+ end
146
+
147
+ # Determines whether the current access token expires within the specified number of seconds.
148
+ #
149
+ # @param seconds [Integer] The number of seconds to check against the token's expiration time.
150
+ #
151
+ # @return [Boolean] Whether the access token expires within the given time frame
152
+ def expires_within? seconds
153
+ # This method is needed for BaseClient
154
+ @expires_at && @expires_at - Time.now.utc < seconds
155
+ end
156
+
157
+ # The universe domain of the impersonated credentials.
158
+ # Effectively this retrieves the universe domain of the source credentials.
159
+ #
160
+ # @return [String] The universe domain of the credentials.
161
+ def universe_domain
162
+ @source_credentials.universe_domain
163
+ end
164
+
165
+ # @return [Logger, nil] The logger of the credentials.
166
+ def logger
167
+ @source_credentials.logger if source_credentials.respond_to? :logger
168
+ end
169
+
170
+ # Creates a duplicate of these credentials without transient token state
171
+ #
172
+ # @param options [Hash] Overrides for the credentials parameters.
173
+ # The following keys are recognized
174
+ # * `base_credentials` the base credentials used to initialize the impersonation
175
+ # * `source_credentials` the authenticated credentials which usually would be
176
+ # base credentials with scope overridden to IAM_SCOPE
177
+ # * `impersonation_url` the URL to use to make an impersonation token exchange
178
+ # * `scope` the scope(s) to access
179
+ #
180
+ # @return [Google::Auth::ImpersonatedServiceAccountCredentials]
181
+ def duplicate options = {}
182
+ options = deep_hash_normalize options
183
+
184
+ options = {
185
+ base_credentials: @base_credentials,
186
+ source_credentials: @source_credentials,
187
+ impersonation_url: @impersonation_url,
188
+ scope: @scope
189
+ }.merge(options)
190
+
191
+ self.class.new options
192
+ end
193
+
194
+ private
195
+
196
+ # Generates a new impersonation access token by exchanging the source credentials' token
197
+ # at the impersonation URL.
198
+ #
199
+ # This method first fetches an access token from the source credentials and then exchanges it
200
+ # for an impersonation token using the specified impersonation URL. The generated token and
201
+ # its expiration time are cached for subsequent use.
202
+ #
203
+ # @param _options [Hash] (optional) Additional options for token retrieval (currently unused).
204
+ #
205
+ # @raise [Signet::UnexpectedStatusError] If the response status is 403 or 500.
206
+ # @raise [Signet::AuthorizationError] For other unexpected response statuses.
207
+ #
208
+ # @return [String] The newly generated impersonation access token.
209
+ def fetch_access_token! _options = {}
210
+ auth_header = {}
211
+ auth_header = @source_credentials.updater_proc.call auth_header
212
+
213
+ resp = connection.post @impersonation_url do |req|
214
+ req.headers.merge! auth_header
215
+ req.headers["Content-Type"] = "application/json"
216
+ req.body = MultiJson.dump({ scope: @scope })
217
+ end
218
+
219
+ case resp.status
220
+ when 200
221
+ response = MultiJson.load resp.body
222
+ self.expires_at = response["expireTime"]
223
+ @access_token = response["accessToken"]
224
+ access_token
225
+ when 403, 500
226
+ msg = "Unexpected error code #{resp.status}.\n #{resp.env.response_body} #{ERROR_SUFFIX}"
227
+ raise Signet::UnexpectedStatusError, msg
228
+ else
229
+ msg = "Unexpected error code #{resp.status}.\n #{resp.env.response_body} #{ERROR_SUFFIX}"
230
+ raise Signet::AuthorizationError, msg
231
+ end
232
+ end
233
+
234
+ # Setter for the expires_at value that makes sure it is converted
235
+ # to Time object.
236
+ def expires_at= new_expires_at
237
+ @expires_at = normalize_timestamp new_expires_at
238
+ end
239
+
240
+ # Returns the type of token (access_token).
241
+ # This method is needed for BaseClient.
242
+ def token_type
243
+ :access_token
244
+ end
245
+
246
+ # Normalizes a timestamp to a Time object.
247
+ #
248
+ # @param time [Time, String, nil] The timestamp to normalize.
249
+ #
250
+ # @return [Time, nil] The normalized Time object, or nil if the input is nil.
251
+ #
252
+ # @raise [RuntimeError] If the input is not a Time, String, or nil.
253
+ def normalize_timestamp time
254
+ case time
255
+ when NilClass
256
+ nil
257
+ when Time
258
+ time
259
+ when String
260
+ Time.parse time
261
+ else
262
+ raise "Invalid time value #{time}"
263
+ end
264
+ end
265
+
266
+ # Convert all keys in this hash (nested) to symbols for uniform retrieval
267
+ def recursive_hash_normalize_keys val
268
+ if val.is_a? Hash
269
+ deep_hash_normalize val
270
+ else
271
+ val
272
+ end
273
+ end
274
+
275
+ def deep_hash_normalize old_hash
276
+ sym_hash = {}
277
+ old_hash&.each { |k, v| sym_hash[k.to_sym] = recursive_hash_normalize_keys v }
278
+ sym_hash
279
+ end
280
+ end
281
+ end
282
+ end
@@ -12,13 +12,15 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ require "jwt"
16
+ require "multi_json"
17
+ require "stringio"
18
+
15
19
  require "google/logging/message"
16
20
  require "googleauth/signet"
17
21
  require "googleauth/credentials_loader"
18
22
  require "googleauth/json_key_reader"
19
- require "jwt"
20
- require "multi_json"
21
- require "stringio"
23
+ require "googleauth/service_account_jwt_header"
22
24
 
23
25
  module Google
24
26
  # Module Auth provides classes that provide Google-specific authorization
@@ -81,6 +83,30 @@ module Google
81
83
  .configure_connection(options)
82
84
  end
83
85
 
86
+ # Creates a duplicate of these credentials
87
+ # without the Signet::OAuth2::Client-specific
88
+ # transient state (e.g. cached tokens)
89
+ #
90
+ # @param options [Hash] Overrides for the credentials parameters.
91
+ # The following keys are recognized in addition to keys in the
92
+ # Signet::OAuth2::Client
93
+ # * `:enable_self_signed_jwt` Whether the self-signed JWT should
94
+ # be used for the authentication
95
+ # * `project_id` the project id to use during the authentication
96
+ # * `quota_project_id` the quota project id to use
97
+ # during the authentication
98
+ def duplicate options = {}
99
+ options = deep_hash_normalize options
100
+ super(
101
+ {
102
+ enable_self_signed_jwt: @enable_self_signed_jwt,
103
+ project_id: project_id,
104
+ quota_project_id: quota_project_id,
105
+ logger: logger
106
+ }.merge(options)
107
+ )
108
+ end
109
+
84
110
  # Handles certain escape sequences that sometimes appear in input.
85
111
  # Specifically, interprets the "\n" sequence for newline, and removes
86
112
  # enclosing quotes.
@@ -112,6 +138,32 @@ module Google
112
138
  super && !enable_self_signed_jwt?
113
139
  end
114
140
 
141
+ # Destructively updates these credentials
142
+ #
143
+ # This method is called by `Signet::OAuth2::Client`'s constructor
144
+ #
145
+ # @param options [Hash] Overrides for the credentials parameters.
146
+ # The following keys are recognized in addition to keys in the
147
+ # Signet::OAuth2::Client
148
+ # * `:enable_self_signed_jwt` Whether the self-signed JWT should
149
+ # be used for the authentication
150
+ # * `project_id` the project id to use during the authentication
151
+ # * `quota_project_id` the quota project id to use
152
+ # during the authentication
153
+ # @return [Google::Auth::ServiceAccountCredentials]
154
+ def update! options = {}
155
+ # Normalize all keys to symbols to allow indifferent access.
156
+ options = deep_hash_normalize options
157
+
158
+ @enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false
159
+ @project_id = options[:project_id] if options.key? :project_id
160
+ @quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
161
+
162
+ super(options)
163
+
164
+ self
165
+ end
166
+
115
167
  private
116
168
 
117
169
  def apply_self_signed_jwt! a_hash
@@ -128,115 +180,5 @@ module Google
128
180
  alt.apply! a_hash
129
181
  end
130
182
  end
131
-
132
- # Authenticates requests using Google's Service Account credentials via
133
- # JWT Header.
134
- #
135
- # This class allows authorizing requests for service accounts directly
136
- # from credentials from a json key file downloaded from the developer
137
- # console (via 'Generate new Json Key'). It is not part of any OAuth2
138
- # flow, rather it creates a JWT and sends that as a credential.
139
- #
140
- # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
141
- class ServiceAccountJwtHeaderCredentials
142
- JWT_AUD_URI_KEY = :jwt_aud_uri
143
- AUTH_METADATA_KEY = Google::Auth::BaseClient::AUTH_METADATA_KEY
144
- TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
145
- SIGNING_ALGORITHM = "RS256".freeze
146
- EXPIRY = 60
147
- extend CredentialsLoader
148
- extend JsonKeyReader
149
- attr_reader :project_id
150
- attr_reader :quota_project_id
151
- attr_accessor :universe_domain
152
- attr_accessor :logger
153
-
154
- # Create a ServiceAccountJwtHeaderCredentials.
155
- #
156
- # @param json_key_io [IO] an IO from which the JSON key can be read
157
- # @param scope [string|array|nil] the scope(s) to access
158
- def self.make_creds options = {}
159
- json_key_io, scope = options.values_at :json_key_io, :scope
160
- new json_key_io: json_key_io, scope: scope
161
- end
162
-
163
- # Initializes a ServiceAccountJwtHeaderCredentials.
164
- #
165
- # @param json_key_io [IO] an IO from which the JSON key can be read
166
- def initialize options = {}
167
- json_key_io = options[:json_key_io]
168
- if json_key_io
169
- @private_key, @issuer, @project_id, @quota_project_id, @universe_domain =
170
- self.class.read_json_key json_key_io
171
- else
172
- @private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
173
- @issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
174
- @project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
175
- @quota_project_id = nil
176
- @universe_domain = nil
177
- end
178
- @universe_domain ||= "googleapis.com"
179
- @project_id ||= CredentialsLoader.load_gcloud_project_id
180
- @signing_key = OpenSSL::PKey::RSA.new @private_key
181
- @scope = options[:scope]
182
- end
183
-
184
- # Construct a jwt token if the JWT_AUD_URI key is present in the input
185
- # hash.
186
- #
187
- # The jwt token is used as the value of a 'Bearer '.
188
- def apply! a_hash, opts = {}
189
- jwt_aud_uri = a_hash.delete JWT_AUD_URI_KEY
190
- return a_hash if jwt_aud_uri.nil? && @scope.nil?
191
- jwt_token = new_jwt_token jwt_aud_uri, opts
192
- a_hash[AUTH_METADATA_KEY] = "Bearer #{jwt_token}"
193
- logger&.debug do
194
- hash = Digest::SHA256.hexdigest jwt_token
195
- Google::Logging::Message.from message: "Sending JWT auth token. (sha256:#{hash})"
196
- end
197
- a_hash
198
- end
199
-
200
- # Returns a clone of a_hash updated with the authorization header
201
- def apply a_hash, opts = {}
202
- a_copy = a_hash.clone
203
- apply! a_copy, opts
204
- a_copy
205
- end
206
-
207
- # Returns a reference to the #apply method, suitable for passing as
208
- # a closure
209
- def updater_proc
210
- proc { |a_hash, opts = {}| apply a_hash, opts }
211
- end
212
-
213
- # Creates a jwt uri token.
214
- def new_jwt_token jwt_aud_uri = nil, options = {}
215
- now = Time.new
216
- skew = options[:skew] || 60
217
- assertion = {
218
- "iss" => @issuer,
219
- "sub" => @issuer,
220
- "exp" => (now + EXPIRY).to_i,
221
- "iat" => (now - skew).to_i
222
- }
223
-
224
- jwt_aud_uri = nil if @scope
225
-
226
- assertion["scope"] = Array(@scope).join " " if @scope
227
- assertion["aud"] = jwt_aud_uri if jwt_aud_uri
228
-
229
- logger&.debug do
230
- Google::Logging::Message.from message: "JWT assertion: #{assertion}"
231
- end
232
-
233
- JWT.encode assertion, @signing_key, SIGNING_ALGORITHM
234
- end
235
-
236
- # Duck-types the corresponding method from BaseClient
237
- def needs_access_token?
238
- false
239
- end
240
- end
241
183
  end
242
184
  end