googleauth 1.8.1 → 1.13.1

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.
@@ -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
@@ -27,7 +27,8 @@ module Google
27
27
  json_key["private_key"],
28
28
  json_key["client_email"],
29
29
  json_key["project_id"],
30
- json_key["quota_project_id"]
30
+ json_key["quota_project_id"],
31
+ json_key["universe_domain"]
31
32
  ]
32
33
  end
33
34
  end
@@ -12,6 +12,7 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ require "google/logging/message"
15
16
  require "googleauth/signet"
16
17
  require "googleauth/credentials_loader"
17
18
  require "googleauth/json_key_reader"
@@ -39,7 +40,11 @@ module Google
39
40
  attr_reader :quota_project_id
40
41
 
41
42
  def enable_self_signed_jwt?
42
- @enable_self_signed_jwt
43
+ # Use a self-singed JWT if there's no information that can be used to
44
+ # obtain an OAuth token, OR if there are scopes but also an assertion
45
+ # that they are default scopes that shouldn't be used to fetch a token,
46
+ # OR we are not in the default universe and thus OAuth isn't supported.
47
+ target_audience.nil? && (scope.nil? || @enable_self_signed_jwt || universe_domain != "googleapis.com")
43
48
  end
44
49
 
45
50
  # Creates a ServiceAccountCredentials.
@@ -53,12 +58,13 @@ module Google
53
58
  raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience
54
59
 
55
60
  if json_key_io
56
- private_key, client_email, project_id, quota_project_id = read_json_key json_key_io
61
+ private_key, client_email, project_id, quota_project_id, universe_domain = read_json_key json_key_io
57
62
  else
58
63
  private_key = unescape ENV[CredentialsLoader::PRIVATE_KEY_VAR]
59
64
  client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
60
65
  project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
61
66
  quota_project_id = nil
67
+ universe_domain = nil
62
68
  end
63
69
  project_id ||= CredentialsLoader.load_gcloud_project_id
64
70
 
@@ -70,10 +76,35 @@ module Google
70
76
  issuer: client_email,
71
77
  signing_key: OpenSSL::PKey::RSA.new(private_key),
72
78
  project_id: project_id,
73
- quota_project_id: quota_project_id)
79
+ quota_project_id: quota_project_id,
80
+ universe_domain: universe_domain || "googleapis.com")
74
81
  .configure_connection(options)
75
82
  end
76
83
 
84
+ # Creates a duplicate of these credentials
85
+ # without the Signet::OAuth2::Client-specific
86
+ # transient state (e.g. cached tokens)
87
+ #
88
+ # @param options [Hash] Overrides for the credentials parameters.
89
+ # The following keys are recognized in addition to keys in the
90
+ # Signet::OAuth2::Client
91
+ # * `:enable_self_signed_jwt` Whether the self-signed JWT should
92
+ # be used for the authentication
93
+ # * `project_id` the project id to use during the authentication
94
+ # * `quota_project_id` the quota project id to use
95
+ # during the authentication
96
+ def duplicate options = {}
97
+ options = deep_hash_normalize options
98
+ super(
99
+ {
100
+ enable_self_signed_jwt: @enable_self_signed_jwt,
101
+ project_id: project_id,
102
+ quota_project_id: quota_project_id,
103
+ logger: logger
104
+ }.merge(options)
105
+ )
106
+ end
107
+
77
108
  # Handles certain escape sequences that sometimes appear in input.
78
109
  # Specifically, interprets the "\n" sequence for newline, and removes
79
110
  # enclosing quotes.
@@ -93,16 +124,44 @@ module Google
93
124
  # Extends the base class to use a transient
94
125
  # ServiceAccountJwtHeaderCredentials for certain cases.
95
126
  def apply! a_hash, opts = {}
96
- # Use a self-singed JWT if there's no information that can be used to
97
- # obtain an OAuth token, OR if there are scopes but also an assertion
98
- # that they are default scopes that shouldn't be used to fetch a token.
99
- if target_audience.nil? && (scope.nil? || enable_self_signed_jwt?)
127
+ if enable_self_signed_jwt?
100
128
  apply_self_signed_jwt! a_hash
101
129
  else
102
130
  super
103
131
  end
104
132
  end
105
133
 
134
+ # Modifies this logic so it also requires self-signed-jwt to be disabled
135
+ def needs_access_token?
136
+ super && !enable_self_signed_jwt?
137
+ end
138
+
139
+ # Destructively updates these credentials
140
+ #
141
+ # This method is called by `Signet::OAuth2::Client`'s constructor
142
+ #
143
+ # @param options [Hash] Overrides for the credentials parameters.
144
+ # The following keys are recognized in addition to keys in the
145
+ # Signet::OAuth2::Client
146
+ # * `:enable_self_signed_jwt` Whether the self-signed JWT should
147
+ # be used for the authentication
148
+ # * `project_id` the project id to use during the authentication
149
+ # * `quota_project_id` the quota project id to use
150
+ # during the authentication
151
+ # @return [Google::Auth::ServiceAccountCredentials]
152
+ def update! options = {}
153
+ # Normalize all keys to symbols to allow indifferent access.
154
+ options = deep_hash_normalize options
155
+
156
+ @enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false
157
+ @project_id = options[:project_id] if options.key? :project_id
158
+ @quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
159
+
160
+ super(options)
161
+
162
+ self
163
+ end
164
+
106
165
  private
107
166
 
108
167
  def apply_self_signed_jwt! a_hash
@@ -115,6 +174,7 @@ module Google
115
174
  }
116
175
  key_io = StringIO.new MultiJson.dump(cred_json)
117
176
  alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io, scope: scope
177
+ alt.logger = logger
118
178
  alt.apply! a_hash
119
179
  end
120
180
  end
@@ -134,10 +194,14 @@ module Google
134
194
  TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
135
195
  SIGNING_ALGORITHM = "RS256".freeze
136
196
  EXPIRY = 60
197
+
137
198
  extend CredentialsLoader
138
199
  extend JsonKeyReader
200
+
139
201
  attr_reader :project_id
140
202
  attr_reader :quota_project_id
203
+ attr_accessor :universe_domain
204
+ attr_accessor :logger
141
205
 
142
206
  # Create a ServiceAccountJwtHeaderCredentials.
143
207
  #
@@ -154,17 +218,46 @@ module Google
154
218
  def initialize options = {}
155
219
  json_key_io = options[:json_key_io]
156
220
  if json_key_io
157
- @private_key, @issuer, @project_id, @quota_project_id =
221
+ @private_key, @issuer, @project_id, @quota_project_id, @universe_domain =
158
222
  self.class.read_json_key json_key_io
159
223
  else
160
- @private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
161
- @issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
162
- @project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
163
- @quota_project_id = nil
224
+ @private_key = options.key?(:private_key) ? options[:private_key] : ENV[CredentialsLoader::PRIVATE_KEY_VAR]
225
+ @issuer = options.key?(:issuer) ? options[:issuer] : ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
226
+ @project_id = options.key?(:project_id) ? options[:project_id] : ENV[CredentialsLoader::PROJECT_ID_VAR]
227
+ @quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
228
+ @universe_domain = options[:universe_domain] if options.key? :universe_domain
164
229
  end
230
+ @universe_domain ||= "googleapis.com"
165
231
  @project_id ||= CredentialsLoader.load_gcloud_project_id
166
232
  @signing_key = OpenSSL::PKey::RSA.new @private_key
167
- @scope = options[:scope]
233
+ @scope = options[:scope] if options.key? :scope
234
+ @logger = options[:logger] if options.key? :scope
235
+ end
236
+
237
+ # Creates a duplicate of these credentials
238
+ #
239
+ # @param options [Hash] Overrides for the credentials parameters.
240
+ # The following keys are recognized
241
+ # * `private key` the private key in string form
242
+ # * `issuer` the SA issuer
243
+ # * `scope` the scope(s) to access
244
+ # * `project_id` the project id to use during the authentication
245
+ # * `quota_project_id` the quota project id to use
246
+ # * `universe_domain` the universe domain of the credentials
247
+ def duplicate options = {}
248
+ options = deep_hash_normalize options
249
+
250
+ options = {
251
+ private_key: @private_key,
252
+ issuer: @issuer,
253
+ scope: @scope,
254
+ project_id: project_id,
255
+ quota_project_id: quota_project_id,
256
+ universe_domain: universe_domain,
257
+ logger: logger
258
+ }.merge(options)
259
+
260
+ self.class.new options
168
261
  end
169
262
 
170
263
  # Construct a jwt token if the JWT_AUD_URI key is present in the input
@@ -176,10 +269,14 @@ module Google
176
269
  return a_hash if jwt_aud_uri.nil? && @scope.nil?
177
270
  jwt_token = new_jwt_token jwt_aud_uri, opts
178
271
  a_hash[AUTH_METADATA_KEY] = "Bearer #{jwt_token}"
272
+ logger&.debug do
273
+ hash = Digest::SHA256.hexdigest jwt_token
274
+ Google::Logging::Message.from message: "Sending JWT auth token. (sha256:#{hash})"
275
+ end
179
276
  a_hash
180
277
  end
181
278
 
182
- # Returns a clone of a_hash updated with the authoriation header
279
+ # Returns a clone of a_hash updated with the authorization header
183
280
  def apply a_hash, opts = {}
184
281
  a_copy = a_hash.clone
185
282
  apply! a_copy, opts
@@ -208,8 +305,34 @@ module Google
208
305
  assertion["scope"] = Array(@scope).join " " if @scope
209
306
  assertion["aud"] = jwt_aud_uri if jwt_aud_uri
210
307
 
308
+ logger&.debug do
309
+ Google::Logging::Message.from message: "JWT assertion: #{assertion}"
310
+ end
311
+
211
312
  JWT.encode assertion, @signing_key, SIGNING_ALGORITHM
212
313
  end
314
+
315
+ # Duck-types the corresponding method from BaseClient
316
+ def needs_access_token?
317
+ false
318
+ end
319
+
320
+ private
321
+
322
+ def deep_hash_normalize old_hash
323
+ sym_hash = {}
324
+ old_hash&.each { |k, v| sym_hash[k.to_sym] = recursive_hash_normalize_keys v }
325
+ sym_hash
326
+ end
327
+
328
+ # Convert all keys in this hash (nested) to symbols for uniform retrieval
329
+ def recursive_hash_normalize_keys val
330
+ if val.is_a? Hash
331
+ deep_hash_normalize val
332
+ else
333
+ val
334
+ end
335
+ end
213
336
  end
214
337
  end
215
338
  end