googleauth 1.3.0 → 1.5.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bc48c47d78d7ec955a2a5557fc8f1cff502a28dd1e18c5af3fc566be5743171
4
- data.tar.gz: 220a8fed81a73d5bc93a2fca2951a749b9469cb769a198cf13564ad7f714ac90
3
+ metadata.gz: cc7032e8f5f0a060f621bd405feccbea08b70b5a8ed7a3d33676ddf1926160a7
4
+ data.tar.gz: 23a199f6a6333ed269c8f564a028533802450c3e7cb100debf5e7e8d940dc8b3
5
5
  SHA512:
6
- metadata.gz: 73f52ffce21a05e15102b54aabbcb3cb199d32e9caf318b125b48b6caeddc01f77c3de4ea09513b0b1e9e503c912e55adf5864b4295b86af0620aa0c7df25df4
7
- data.tar.gz: 7ec107faa35d72aa1fd8e79b86b30df9acf061ce86ac52641bc12b69391f0b4f2adde8021b908327e379dee65c1e2ed7ed1b203e629ca1f4d25a988e80c31eb2
6
+ metadata.gz: 8b2142a0bdeffd451c3f98c97c8414b26a63939dcd5b4430cb9299b3f1ecdf12d8cc39b8cf813531034dc63121764600c386b372ffe819fdd202c51b070acef2
7
+ data.tar.gz: 56e4c86362466b27ad68ec388138fb469b44e604aa60f40454f7499210f61c16e19e559ecd9358023a9de74a8f21289410f5855e128598ac1206c341c328997d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Release History
2
2
 
3
+ ### 1.5.2 (2023-04-13)
4
+
5
+ #### Bug Fixes
6
+
7
+ * AWS IMDSV2 session token fetching shall call PUT method instead of GET ([#429](https://github.com/googleapis/google-auth-library-ruby/issues/429))
8
+ * GCECredentials - Allow retrieval of ID token ([#425](https://github.com/googleapis/google-auth-library-ruby/issues/425))
9
+
10
+ ### 1.5.1 (2023-04-10)
11
+
12
+ #### Bug Fixes
13
+
14
+ * Remove external account config validation ([#427](https://github.com/googleapis/google-auth-library-ruby/issues/427))
15
+
16
+ ### 1.5.0 (2023-03-21)
17
+
18
+ #### Features
19
+
20
+ * Add support for AWS Workload Identity Federation ([#418](https://github.com/googleapis/google-auth-library-ruby/issues/418))
21
+
22
+ ### 1.4.0 (2022-12-14)
23
+
24
+ #### Features
25
+
26
+ * make new_jwt_token public in order to fetch raw token directly ([#405](https://github.com/googleapis/google-auth-library-ruby/issues/405))
27
+
3
28
  ### 1.3.0 (2022-10-18)
4
29
 
5
30
  #### Features
@@ -60,7 +60,7 @@ module Google
60
60
  GCECredentials.unmemoize_all
61
61
  raise NOT_FOUND_ERROR
62
62
  end
63
- GCECredentials.new scope: scope
63
+ GCECredentials.new options.merge(scope: scope)
64
64
  end
65
65
  end
66
66
  end
@@ -0,0 +1,80 @@
1
+ # Copyright 2023 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
+ module Google
16
+ # Module Auth provides classes that provide Google-specific authorization
17
+ # used to access Google APIs.
18
+ module Auth
19
+ # BaseClient is a class used to contain common methods that are required by any
20
+ # Credentials Client, including AwsCredentials, ServiceAccountCredentials,
21
+ # and UserRefreshCredentials. This is a superclass of Signet::OAuth2::Client
22
+ # and has been created to create a generic interface for all credentials clients
23
+ # to use, including ones which do not inherit from Signet::OAuth2::Client.
24
+ module BaseClient
25
+ AUTH_METADATA_KEY = :authorization
26
+
27
+ # Updates a_hash updated with the authentication token
28
+ def apply! a_hash, opts = {}
29
+ # fetch the access token there is currently not one, or if the client
30
+ # has expired
31
+ fetch_access_token! opts if needs_access_token?
32
+ a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}"
33
+ end
34
+
35
+ # Returns a clone of a_hash updated with the authentication token
36
+ def apply a_hash, opts = {}
37
+ a_copy = a_hash.clone
38
+ apply! a_copy, opts
39
+ a_copy
40
+ end
41
+
42
+ # Whether the id_token or access_token is missing or about to expire.
43
+ def needs_access_token?
44
+ send(token_type).nil? || expires_within?(60)
45
+ end
46
+
47
+ # Returns a reference to the #apply method, suitable for passing as
48
+ # a closure
49
+ def updater_proc
50
+ proc { |a_hash, opts = {}| apply a_hash, opts }
51
+ end
52
+
53
+ def on_refresh &block
54
+ @refresh_listeners = [] unless defined? @refresh_listeners
55
+ @refresh_listeners << block
56
+ end
57
+
58
+ def notify_refresh_listeners
59
+ listeners = defined?(@refresh_listeners) ? @refresh_listeners : []
60
+ listeners.each do |block|
61
+ block.call self
62
+ end
63
+ end
64
+
65
+ def expires_within?
66
+ raise NotImplementedError
67
+ end
68
+
69
+ private
70
+
71
+ def token_type
72
+ raise NotImplementedError
73
+ end
74
+
75
+ def fetch_access_token!
76
+ raise NotImplementedError
77
+ end
78
+ end
79
+ end
80
+ end
@@ -355,7 +355,7 @@ module Google
355
355
  @project_id = options["project_id"] || options["project"]
356
356
  @quota_project_id = options["quota_project_id"]
357
357
  case keyfile
358
- when Signet::OAuth2::Client
358
+ when Google::Auth::BaseClient
359
359
  update_from_signet keyfile
360
360
  when Hash
361
361
  update_from_hash keyfile, options
@@ -30,6 +30,11 @@ module Google
30
30
  REFRESH_TOKEN_VAR = "GOOGLE_REFRESH_TOKEN".freeze
31
31
  ACCOUNT_TYPE_VAR = "GOOGLE_ACCOUNT_TYPE".freeze
32
32
  PROJECT_ID_VAR = "GOOGLE_PROJECT_ID".freeze
33
+ AWS_REGION_VAR = "AWS_REGION".freeze
34
+ AWS_DEFAULT_REGION_VAR = "AWS_DEFAULT_REGION".freeze
35
+ AWS_ACCESS_KEY_ID_VAR = "AWS_ACCESS_KEY_ID".freeze
36
+ AWS_SECRET_ACCESS_KEY_VAR = "AWS_SECRET_ACCESS_KEY".freeze
37
+ AWS_SESSION_TOKEN_VAR = "AWS_SESSION_TOKEN".freeze
33
38
  GCLOUD_POSIX_COMMAND = "gcloud".freeze
34
39
  GCLOUD_WINDOWS_COMMAND = "gcloud.cmd".freeze
35
40
  GCLOUD_CONFIG_COMMAND = "config config-helper --format json --verbosity none".freeze
@@ -18,6 +18,7 @@ require "stringio"
18
18
  require "googleauth/credentials_loader"
19
19
  require "googleauth/service_account"
20
20
  require "googleauth/user_refresh"
21
+ require "googleauth/external_account"
21
22
 
22
23
  module Google
23
24
  # Module Auth provides classes that provide Google-specific authorization
@@ -53,6 +54,8 @@ module Google
53
54
  ServiceAccountCredentials
54
55
  when "authorized_user"
55
56
  UserRefreshCredentials
57
+ when "external_account"
58
+ ExternalAccount::Credentials
56
59
  else
57
60
  raise "credentials type '#{type}' is not supported"
58
61
  end
@@ -69,6 +72,8 @@ module Google
69
72
  [json_key, ServiceAccountCredentials]
70
73
  when "authorized_user"
71
74
  [json_key, UserRefreshCredentials]
75
+ when "external_account"
76
+ [json_key, ExternalAccount::Credentials]
72
77
  else
73
78
  raise "credentials type '#{type}' is not supported"
74
79
  end
@@ -0,0 +1,376 @@
1
+ # Copyright 2023 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 "time"
16
+ require "googleauth/external_account/base_credentials"
17
+
18
+ module Google
19
+ # Module Auth provides classes that provide Google-specific authorization used to access Google APIs.
20
+ module Auth
21
+ # Authenticates requests using External Account credentials, such as those provided by the AWS provider.
22
+ module ExternalAccount
23
+ # This module handles the retrieval of credentials from Google Cloud by utilizing the AWS EC2 metadata service and
24
+ # then exchanging the credentials for a short-lived Google Cloud access token.
25
+ class AwsCredentials
26
+ # Constant for imdsv2 session token expiration in seconds
27
+ IMDSV2_TOKEN_EXPIRATION_IN_SECONDS = 300
28
+
29
+ include Google::Auth::ExternalAccount::BaseCredentials
30
+ extend CredentialsLoader
31
+
32
+ # Will always be nil, but method still gets used.
33
+ attr_reader :client_id
34
+
35
+ def initialize options = {}
36
+ base_setup options
37
+
38
+ @audience = options[:audience]
39
+ @credential_source = options[:credential_source] || {}
40
+ @environment_id = @credential_source["environment_id"]
41
+ @region_url = @credential_source["region_url"]
42
+ @credential_verification_url = @credential_source["url"]
43
+ @regional_cred_verification_url = @credential_source["regional_cred_verification_url"]
44
+ @imdsv2_session_token_url = @credential_source["imdsv2_session_token_url"]
45
+
46
+ # These will be lazily loaded when needed, or will raise an error if not provided
47
+ @region = nil
48
+ @request_signer = nil
49
+ @imdsv2_session_token = nil
50
+ @imdsv2_session_token_expiry = nil
51
+ end
52
+
53
+ # Retrieves the subject token using the credential_source object.
54
+ # The subject token is a serialized [AWS GetCallerIdentity signed request](
55
+ # https://cloud.google.com/iam/docs/access-resources-aws#exchange-token).
56
+ #
57
+ # The logic is summarized as:
58
+ #
59
+ # Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION environment variable or from the AWS
60
+ # metadata server availability-zone if not found in the environment variable.
61
+ #
62
+ # Check AWS credentials in environment variables. If not found, retrieve from the AWS metadata server
63
+ # security-credentials endpoint.
64
+ #
65
+ # When retrieving AWS credentials from the metadata server security-credentials endpoint, the AWS role needs to
66
+ # be determined by # calling the security-credentials endpoint without any argument.
67
+ # Then the credentials can be retrieved via: security-credentials/role_name
68
+ #
69
+ # Generate the signed request to AWS STS GetCallerIdentity action.
70
+ #
71
+ # Inject x-goog-cloud-target-resource into header and serialize the signed request.
72
+ # This will be the subject-token to pass to GCP STS.
73
+ #
74
+ # @return [string] The retrieved subject token.
75
+ #
76
+ def retrieve_subject_token!
77
+ if @request_signer.nil?
78
+ @region = region
79
+ @request_signer = AwsRequestSigner.new @region
80
+ end
81
+
82
+ request = {
83
+ method: "POST",
84
+ url: @regional_cred_verification_url.sub("{region}", @region)
85
+ }
86
+
87
+ request_options = @request_signer.generate_signed_request fetch_security_credentials, request
88
+
89
+ request_headers = request_options[:headers]
90
+ request_headers["x-goog-cloud-target-resource"] = @audience
91
+
92
+ aws_signed_request = {
93
+ headers: [],
94
+ method: request_options[:method],
95
+ url: request_options[:url]
96
+ }
97
+
98
+ aws_signed_request[:headers] = request_headers.keys.sort.map do |key|
99
+ { key: key, value: request_headers[key] }
100
+ end
101
+
102
+ uri_escape aws_signed_request.to_json
103
+ end
104
+
105
+ private
106
+
107
+ def imdsv2_session_token
108
+ return @imdsv2_session_token unless imdsv2_session_token_invalid?
109
+ raise "IMDSV2 token url must be provided" if @imdsv2_session_token_url.nil?
110
+ begin
111
+ response = connection.put @imdsv2_session_token_url do |req|
112
+ req.headers["x-aws-ec2-metadata-token-ttl-seconds"] = IMDSV2_TOKEN_EXPIRATION_IN_SECONDS.to_s
113
+ end
114
+ rescue Faraday::Error => e
115
+ raise "Fetching AWS IMDSV2 token error: #{e}"
116
+ end
117
+ raise Faraday::Error unless response.success?
118
+ @imdsv2_session_token = response.body
119
+ @imdsv2_session_token_expiry = Time.now + IMDSV2_TOKEN_EXPIRATION_IN_SECONDS
120
+ @imdsv2_session_token
121
+ end
122
+
123
+ def imdsv2_session_token_invalid?
124
+ return true if @imdsv2_session_token.nil?
125
+ @imdsv2_session_token_expiry.nil? || @imdsv2_session_token_expiry < Time.now
126
+ end
127
+
128
+ def get_aws_resource url, name, data: nil, headers: {}
129
+ begin
130
+ headers["x-aws-ec2-metadata-token"] = imdsv2_session_token
131
+ response = if data
132
+ headers["Content-Type"] = "application/json"
133
+ connection.post url, data, headers
134
+ else
135
+ connection.get url, nil, headers
136
+ end
137
+
138
+ raise Faraday::Error unless response.success?
139
+ response
140
+ rescue Faraday::Error
141
+ raise "Failed to retrieve AWS #{name}."
142
+ end
143
+ end
144
+
145
+ def uri_escape string
146
+ if string.nil?
147
+ nil
148
+ else
149
+ CGI.escape(string.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~")
150
+ end
151
+ end
152
+
153
+ # Retrieves the AWS security credentials required for signing AWS requests from either the AWS security
154
+ # credentials environment variables or from the AWS metadata server.
155
+ def fetch_security_credentials
156
+ env_aws_access_key_id = ENV[CredentialsLoader::AWS_ACCESS_KEY_ID_VAR]
157
+ env_aws_secret_access_key = ENV[CredentialsLoader::AWS_SECRET_ACCESS_KEY_VAR]
158
+ # This is normally not available for permanent credentials.
159
+ env_aws_session_token = ENV[CredentialsLoader::AWS_SESSION_TOKEN_VAR]
160
+
161
+ if env_aws_access_key_id && env_aws_secret_access_key
162
+ return {
163
+ access_key_id: env_aws_access_key_id,
164
+ secret_access_key: env_aws_secret_access_key,
165
+ session_token: env_aws_session_token
166
+ }
167
+ end
168
+
169
+ role_name = fetch_metadata_role_name
170
+ credentials = fetch_metadata_security_credentials role_name
171
+
172
+ {
173
+ access_key_id: credentials["AccessKeyId"],
174
+ secret_access_key: credentials["SecretAccessKey"],
175
+ session_token: credentials["Token"]
176
+ }
177
+ end
178
+
179
+ # Retrieves the AWS role currently attached to the current AWS workload by querying the AWS metadata server.
180
+ # This is needed for the AWS metadata server security credentials endpoint in order to retrieve the AWS security
181
+ # credentials needed to sign requests to AWS APIs.
182
+ def fetch_metadata_role_name
183
+ unless @credential_verification_url
184
+ raise "Unable to determine the AWS metadata server security credentials endpoint"
185
+ end
186
+
187
+ get_aws_resource(@credential_verification_url, "IAM Role").body
188
+ end
189
+
190
+ # Retrieves the AWS security credentials required for signing AWS requests from the AWS metadata server.
191
+ def fetch_metadata_security_credentials role_name
192
+ response = get_aws_resource "#{@credential_verification_url}/#{role_name}", "credentials"
193
+ MultiJson.load response.body
194
+ end
195
+
196
+ def region
197
+ @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR]
198
+
199
+ unless @region
200
+ raise "region_url or region must be set for external account credentials" unless @region_url
201
+
202
+ @region ||= get_aws_resource(@region_url, "region").body[0..-2]
203
+ end
204
+
205
+ @region
206
+ end
207
+ end
208
+
209
+ # Implements an AWS request signer based on the AWS Signature Version 4 signing process.
210
+ # https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
211
+ class AwsRequestSigner
212
+ # Instantiates an AWS request signer used to compute authenticated signed requests to AWS APIs based on the AWS
213
+ # Signature Version 4 signing process.
214
+ #
215
+ # @param [string] region_name
216
+ # The AWS region to use.
217
+ def initialize region_name
218
+ @region_name = region_name
219
+ end
220
+
221
+ # Generates the signed request for the provided HTTP request for calling
222
+ # an AWS API. This follows the steps described at:
223
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
224
+ #
225
+ # @param [Hash[string, string]] aws_security_credentials
226
+ # A dictionary containing the AWS security credentials.
227
+ # @param [string] url
228
+ # The AWS service URL containing the canonical URI and query string.
229
+ # @param [string] method
230
+ # The HTTP method used to call this API.
231
+ #
232
+ # @return [hash{string => string}]
233
+ # The AWS signed request dictionary object.
234
+ #
235
+ def generate_signed_request aws_credentials, original_request
236
+ uri = Addressable::URI.parse original_request[:url]
237
+ raise "Invalid AWS service URL" unless uri.hostname && uri.scheme == "https"
238
+ service_name = uri.host.split(".").first
239
+
240
+ datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ"
241
+ date = datetime[0, 8]
242
+
243
+ headers = aws_headers aws_credentials, original_request, datetime
244
+
245
+ request_payload = original_request[:data] || ""
246
+ content_sha256 = sha256_hexdigest request_payload
247
+
248
+ canonical_req = canonical_request original_request[:method], uri, headers, content_sha256
249
+ sts = string_to_sign datetime, canonical_req, service_name
250
+
251
+ # Authorization header requires everything else to be properly setup in order to be properly
252
+ # calculated.
253
+ headers["Authorization"] = build_authorization_header headers, sts, aws_credentials, service_name, date
254
+
255
+ {
256
+ url: uri.to_s,
257
+ headers: headers,
258
+ method: original_request[:method],
259
+ data: (request_payload unless request_payload.empty?)
260
+ }.compact
261
+ end
262
+
263
+ private
264
+
265
+ def aws_headers aws_credentials, original_request, datetime
266
+ uri = Addressable::URI.parse original_request[:url]
267
+ temp_headers = original_request[:headers] || {}
268
+ headers = {}
269
+ temp_headers.each_key { |k| headers[k.to_s] = temp_headers[k] }
270
+ headers["host"] = uri.host
271
+ headers["x-amz-date"] = datetime
272
+ headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token]
273
+ headers
274
+ end
275
+
276
+ def build_authorization_header headers, sts, aws_credentials, service_name, date
277
+ [
278
+ "AWS4-HMAC-SHA256",
279
+ "Credential=#{credential aws_credentials[:access_key_id], date, service_name},",
280
+ "SignedHeaders=#{headers.keys.sort.join ';'},",
281
+ "Signature=#{signature aws_credentials[:secret_access_key], date, sts, service_name}"
282
+ ].join(" ")
283
+ end
284
+
285
+ def signature secret_access_key, date, string_to_sign, service
286
+ k_date = hmac "AWS4#{secret_access_key}", date
287
+ k_region = hmac k_date, @region_name
288
+ k_service = hmac k_region, service
289
+ k_credentials = hmac k_service, "aws4_request"
290
+
291
+ hexhmac k_credentials, string_to_sign
292
+ end
293
+
294
+ def hmac key, value
295
+ OpenSSL::HMAC.digest OpenSSL::Digest.new("sha256"), key, value
296
+ end
297
+
298
+ def hexhmac key, value
299
+ OpenSSL::HMAC.hexdigest OpenSSL::Digest.new("sha256"), key, value
300
+ end
301
+
302
+ def credential access_key_id, date, service
303
+ "#{access_key_id}/#{credential_scope date, service}"
304
+ end
305
+
306
+ def credential_scope date, service
307
+ [
308
+ date,
309
+ @region_name,
310
+ service,
311
+ "aws4_request"
312
+ ].join("/")
313
+ end
314
+
315
+ def string_to_sign datetime, canonical_request, service
316
+ [
317
+ "AWS4-HMAC-SHA256",
318
+ datetime,
319
+ credential_scope(datetime[0, 8], service),
320
+ sha256_hexdigest(canonical_request)
321
+ ].join("\n")
322
+ end
323
+
324
+ def host uri
325
+ # Handles known and unknown URI schemes; default_port nil when unknown.
326
+ if uri.default_port == uri.port
327
+ uri.host
328
+ else
329
+ "#{uri.host}:#{uri.port}"
330
+ end
331
+ end
332
+
333
+ def canonical_request http_method, uri, headers, content_sha256
334
+ headers = headers.sort_by(&:first) # transforms to a sorted array of [key, value]
335
+
336
+ [
337
+ http_method,
338
+ uri.path.empty? ? "/" : uri.path,
339
+ build_canonical_querystring(uri.query || ""),
340
+ headers.map { |k, v| "#{k}:#{v}\n" }.join, # Canonical headers
341
+ headers.map(&:first).join(";"), # Signed headers
342
+ content_sha256
343
+ ].join("\n")
344
+ end
345
+
346
+ def sha256_hexdigest string
347
+ OpenSSL::Digest::SHA256.hexdigest string
348
+ end
349
+
350
+ # Generates the canonical query string given a raw query string.
351
+ # Logic is based on
352
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
353
+ # Code is from the AWS SDK for Ruby
354
+ # https://github.com/aws/aws-sdk-ruby/blob/0ac3d0a393ed216290bfb5f0383380376f6fb1f1/gems/aws-sigv4/lib/aws-sigv4/signer.rb#L532
355
+ def build_canonical_querystring query
356
+ params = query.split "&"
357
+ params = params.map { |p| p.include?("=") ? p : "#{p}=" }
358
+
359
+ params.each.with_index.sort do |(a, a_offset), (b, b_offset)|
360
+ a_name, a_value = a.split "="
361
+ b_name, b_value = b.split "="
362
+ if a_name == b_name
363
+ if a_value == b_value
364
+ a_offset <=> b_offset
365
+ else
366
+ a_value <=> b_value
367
+ end
368
+ else
369
+ a_name <=> b_name
370
+ end
371
+ end.map(&:first).join("&")
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,200 @@
1
+ # Copyright 2023 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.require "time"
14
+
15
+ require "googleauth/base_client"
16
+ require "googleauth/helpers/connection"
17
+ require "googleauth/oauth2/sts_client"
18
+
19
+ module Google
20
+ # Module Auth provides classes that provide Google-specific authorization
21
+ # used to access Google APIs.
22
+ module Auth
23
+ # Authenticates requests using External Account credentials, such
24
+ # as those provided by the AWS provider.
25
+ module ExternalAccount
26
+ # Authenticates requests using External Account credentials, such
27
+ # as those provided by the AWS provider.
28
+ module BaseCredentials
29
+ # Contains all methods needed for all external account credentials.
30
+ # Other credentials should call `base_setup` during initialization
31
+ # And should define the :retrieve_subject_token method
32
+
33
+ # External account JSON type identifier.
34
+ EXTERNAL_ACCOUNT_JSON_TYPE = "external_account".freeze
35
+ # The token exchange grant_type used for exchanging credentials.
36
+ STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
37
+ # The token exchange requested_token_type. This is always an access_token.
38
+ STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze
39
+ # Cloud resource manager URL used to retrieve project information.
40
+ CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/".freeze
41
+ # Default IAM_SCOPE
42
+ IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze
43
+
44
+ include Google::Auth::BaseClient
45
+ include Helpers::Connection
46
+
47
+ attr_reader :expires_at
48
+ attr_accessor :access_token
49
+
50
+ def expires_within? seconds
51
+ # This method is needed for BaseClient
52
+ @expires_at && @expires_at - Time.now.utc < seconds
53
+ end
54
+
55
+ def expires_at= new_expires_at
56
+ @expires_at = normalize_timestamp new_expires_at
57
+ end
58
+
59
+ def fetch_access_token! _options = {}
60
+ # This method is needed for BaseClient
61
+ response = exchange_token
62
+
63
+ if @service_account_impersonation_url
64
+ impersonated_response = get_impersonated_access_token response["access_token"]
65
+ self.expires_at = impersonated_response["expireTime"]
66
+ self.access_token = impersonated_response["accessToken"]
67
+ else
68
+ # Extract the expiration time in seconds from the response and calculate the actual expiration time
69
+ # and then save that to the expiry variable.
70
+ self.expires_at = Time.now.utc + response["expires_in"].to_i
71
+ self.access_token = response["access_token"]
72
+ end
73
+
74
+ notify_refresh_listeners
75
+ end
76
+
77
+ ##
78
+ # Retrieves the project ID corresponding to the workload identity or workforce pool.
79
+ # For workforce pool credentials, it returns the project ID corresponding to the workforce_pool_user_project.
80
+ # When not determinable, None is returned.
81
+ #
82
+ # The resource may not have permission (resourcemanager.projects.get) to
83
+ # call this API or the required scopes may not be selected:
84
+ # https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
85
+ #
86
+ # @return [string,nil]
87
+ # The project ID corresponding to the workload identity pool or workforce pool if determinable.
88
+ #
89
+ def project_id
90
+ return @project_id unless @project_id.nil?
91
+ project_number = self.project_number || @workforce_pool_user_project
92
+
93
+ # if we missing either project number or scope, we won't retrieve project_id
94
+ return nil if project_number.nil? || @scope.nil?
95
+
96
+ url = "#{CLOUD_RESOURCE_MANAGER}#{project_number}"
97
+
98
+ response = connection.get url do |req|
99
+ req.headers["Authorization"] = "Bearer #{@access_token}"
100
+ req.headers["Content-Type"] = "application/json"
101
+ end
102
+
103
+ if response.status == 200
104
+ response_data = MultiJson.load response.body, symbolize_names: true
105
+ @project_id = response_data[:projectId]
106
+ end
107
+
108
+ @project_id
109
+ end
110
+
111
+ ##
112
+ # Retrieve the project number corresponding to workload identity pool
113
+ # STS audience pattern:
114
+ # `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...`
115
+ #
116
+ # @return [string, nil]
117
+ #
118
+ def project_number
119
+ segments = @audience.split "/"
120
+ idx = segments.index "projects"
121
+ return nil if idx.nil? || idx + 1 == segments.size
122
+ segments[idx + 1]
123
+ end
124
+
125
+ private
126
+
127
+ def token_type
128
+ # This method is needed for BaseClient
129
+ :access_token
130
+ end
131
+
132
+ def base_setup options
133
+ self.default_connection = options[:connection]
134
+
135
+ @audience = options[:audience]
136
+ @scope = options[:scope] || IAM_SCOPE
137
+ @subject_token_type = options[:subject_token_type]
138
+ @token_url = options[:token_url]
139
+ @service_account_impersonation_url = options[:service_account_impersonation_url]
140
+ @service_account_impersonation_options = options[:service_account_impersonation_options] || {}
141
+ @client_id = options[:client_id]
142
+ @client_secret = options[:client_secret]
143
+ @quota_project_id = options[:quota_project_id]
144
+ @project_id = nil
145
+ @workforce_pool_user_project = [:workforce_pool_user_project]
146
+
147
+ @expires_at = nil
148
+ @access_token = nil
149
+
150
+ @sts_client = Google::Auth::OAuth2::STSClient.new(
151
+ token_exchange_endpoint: @token_url,
152
+ connection: default_connection
153
+ )
154
+ end
155
+
156
+ def normalize_timestamp time
157
+ case time
158
+ when NilClass
159
+ nil
160
+ when Time
161
+ time
162
+ when String
163
+ Time.parse time
164
+ else
165
+ raise "Invalid time value #{time}"
166
+ end
167
+ end
168
+
169
+ def exchange_token
170
+ @sts_client.exchange_token(
171
+ audience: @audience,
172
+ grant_type: STS_GRANT_TYPE,
173
+ subject_token: retrieve_subject_token!,
174
+ subject_token_type: @subject_token_type,
175
+ scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope,
176
+ requested_token_type: STS_REQUESTED_TOKEN_TYPE
177
+ )
178
+ end
179
+
180
+ def get_impersonated_access_token token, _options = {}
181
+ response = connection.post @service_account_impersonation_url do |req|
182
+ req.headers["Authorization"] = "Bearer #{token}"
183
+ req.headers["Content-Type"] = "application/json"
184
+ req.body = MultiJson.dump({ scope: @scope })
185
+ end
186
+
187
+ if response.status != 200
188
+ raise "Service account impersonation failed with status #{response.status}"
189
+ end
190
+
191
+ MultiJson.load response.body
192
+ end
193
+
194
+ def retrieve_subject_token!
195
+ raise NotImplementedError
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,70 @@
1
+ # Copyright 2022 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 "time"
16
+ require "uri"
17
+ require "googleauth/credentials_loader"
18
+ require "googleauth/external_account/aws_credentials"
19
+
20
+ module Google
21
+ # Module Auth provides classes that provide Google-specific authorization
22
+ # used to access Google APIs.
23
+ module Auth
24
+ # Authenticates requests using External Account credentials, such
25
+ # as those provided by the AWS provider.
26
+ module ExternalAccount
27
+ # Provides an entrypoint for all Exernal Account credential classes.
28
+ class Credentials
29
+ # The subject token type used for AWS external_account credentials.
30
+ AWS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:aws:token-type:aws4_request".freeze
31
+ AWS_SUBJECT_TOKEN_INVALID = "aws is the only currently supported external account type".freeze
32
+
33
+ # Create a ExternalAccount::Credentials
34
+ #
35
+ # @param json_key_io [IO] an IO from which the JSON key can be read
36
+ # @param scope [String,Array,nil] the scope(s) to access
37
+ def self.make_creds options = {}
38
+ json_key_io, scope = options.values_at :json_key_io, :scope
39
+
40
+ raise "A json file is required for external account credentials." unless json_key_io
41
+ user_creds = read_json_key json_key_io
42
+
43
+ # TODO: check for other External Account Credential types. Currently only AWS is supported.
44
+ raise AWS_SUBJECT_TOKEN_INVALID unless user_creds["subject_token_type"] == AWS_SUBJECT_TOKEN_TYPE
45
+
46
+ Google::Auth::ExternalAccount::AwsCredentials.new(
47
+ audience: user_creds["audience"],
48
+ scope: scope,
49
+ subject_token_type: user_creds["subject_token_type"],
50
+ token_url: user_creds["token_url"],
51
+ credential_source: user_creds["credential_source"],
52
+ service_account_impersonation_url: user_creds["service_account_impersonation_url"]
53
+ )
54
+ end
55
+
56
+ # Reads the required fields from the JSON.
57
+ def self.read_json_key json_key_io
58
+ json_key = MultiJson.load json_key_io.read
59
+ wanted = [
60
+ "audience", "subject_token_type", "token_url", "credential_source"
61
+ ]
62
+ wanted.each do |key|
63
+ raise "the json is missing the #{key} field" unless json_key.key? key
64
+ end
65
+ json_key
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,35 @@
1
+ # Copyright 2023 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 "faraday"
16
+
17
+ module Google
18
+ # Module Auth provides classes that provide Google-specific authorization
19
+ # used to access Google APIs.
20
+ module Auth
21
+ # Helpers provides utility methods for Google::Auth.
22
+ module Helpers
23
+ # Connection provides a Faraday connection for use with Google::Auth.
24
+ module Connection
25
+ module_function
26
+
27
+ attr_accessor :default_connection
28
+
29
+ def connection
30
+ @default_connection || Faraday.default_connection
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,99 @@
1
+ # Copyright 2023 Google LLC
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/helpers/connection"
16
+
17
+ module Google
18
+ module Auth
19
+ module OAuth2
20
+ # OAuth 2.0 Token Exchange Spec.
21
+ # This module defines a token exchange utility based on the
22
+ # [OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693) spec. This will be mainly
23
+ # used to exchange external credentials for GCP access tokens in workload identity pools to
24
+ # access Google APIs.
25
+ # The implementation will support various types of client authentication as allowed in the spec.
26
+ #
27
+ # A deviation on the spec will be for additional Google specific options that cannot be easily
28
+ # mapped to parameters defined in the RFC.
29
+ # The returned dictionary response will be based on the [rfc8693 section 2.2.1]
30
+ # (https://tools.ietf.org/html/rfc8693#section-2.2.1) spec JSON response.
31
+ #
32
+ class STSClient
33
+ include Helpers::Connection
34
+
35
+ URLENCODED_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }.freeze
36
+
37
+ # Create a new instance of the STSClient.
38
+ #
39
+ # @param [String] token_exchange_endpoint
40
+ # The token exchange endpoint.
41
+ def initialize options = {}
42
+ raise "Token exchange endpoint can not be nil" if options[:token_exchange_endpoint].nil?
43
+ self.default_connection = options[:connection]
44
+ @token_exchange_endpoint = options[:token_exchange_endpoint]
45
+ end
46
+
47
+ # Exchanges the provided token for another type of token based on the
48
+ # rfc8693 spec
49
+ #
50
+ # @param [Faraday instance] connection
51
+ # A callable faraday instance used to make HTTP requests.
52
+ # @param [String] grant_type
53
+ # The OAuth 2.0 token exchange grant type.
54
+ # @param [String] subject_token
55
+ # The OAuth 2.0 token exchange subject token.
56
+ # @param [String] subject_token_type
57
+ # The OAuth 2.0 token exchange subject token type.
58
+ # @param [String] resource
59
+ # The optional OAuth 2.0 token exchange resource field.
60
+ # @param [String] audience
61
+ # The optional OAuth 2.0 token exchange audience field.
62
+ # @param [Array<String>] scopes
63
+ # The optional list of scopes to use.
64
+ # @param [String] requested_token_type
65
+ # The optional OAuth 2.0 token exchange requested token type.
66
+ # @param additional_headers (Hash<String,String>):
67
+ # The optional additional headers to pass to the token exchange endpoint.
68
+ #
69
+ # @return [Hash] A hash containing the token exchange response.
70
+ def exchange_token options = {}
71
+ missing_required_opts = [:grant_type, :subject_token, :subject_token_type] - options.keys
72
+ unless missing_required_opts.empty?
73
+ raise ArgumentError, "Missing required options: #{missing_required_opts.join ', '}"
74
+ end
75
+
76
+ # TODO: Add the ability to add authentication to the headers
77
+ headers = URLENCODED_HEADERS.dup.merge(options[:additional_headers] || {})
78
+
79
+ request_body = {
80
+ grant_type: options[:grant_type],
81
+ audience: options[:audience],
82
+ scope: Array(options[:scopes])&.join(" ") || [],
83
+ requested_token_type: options[:requested_token_type],
84
+ subject_token: options[:subject_token],
85
+ subject_token_type: options[:subject_token_type]
86
+ }
87
+
88
+ response = connection.post @token_exchange_endpoint, URI.encode_www_form(request_body), headers
89
+
90
+ if response.status != 200
91
+ raise "Token exchange failed with status #{response.status}"
92
+ end
93
+
94
+ MultiJson.load response.body
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -130,7 +130,7 @@ module Google
130
130
  # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
131
131
  class ServiceAccountJwtHeaderCredentials
132
132
  JWT_AUD_URI_KEY = :jwt_aud_uri
133
- AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY
133
+ AUTH_METADATA_KEY = Google::Auth::BaseClient::AUTH_METADATA_KEY
134
134
  TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
135
135
  SIGNING_ALGORITHM = "RS256".freeze
136
136
  EXPIRY = 60
@@ -192,8 +192,6 @@ module Google
192
192
  proc { |a_hash, opts = {}| apply a_hash, opts }
193
193
  end
194
194
 
195
- protected
196
-
197
195
  # Creates a jwt uri token.
198
196
  def new_jwt_token jwt_aud_uri = nil, options = {}
199
197
  now = Time.new
@@ -13,16 +13,18 @@
13
13
  # limitations under the License.
14
14
 
15
15
  require "signet/oauth_2/client"
16
+ require "googleauth/base_client"
16
17
 
17
18
  module Signet
18
19
  # OAuth2 supports OAuth2 authentication.
19
20
  module OAuth2
20
- AUTH_METADATA_KEY = :authorization
21
21
  # Signet::OAuth2::Client creates an OAuth2 client
22
22
  #
23
23
  # This reopens Client to add #apply and #apply! methods which update a
24
24
  # hash with the fetched authentication token.
25
25
  class Client
26
+ include Google::Auth::BaseClient
27
+
26
28
  def configure_connection options
27
29
  @connection_info =
28
30
  options[:connection_builder] || options[:default_connection]
@@ -34,37 +36,6 @@ module Signet
34
36
  target_audience ? :id_token : :access_token
35
37
  end
36
38
 
37
- # Whether the id_token or access_token is missing or about to expire.
38
- def needs_access_token?
39
- send(token_type).nil? || expires_within?(60)
40
- end
41
-
42
- # Updates a_hash updated with the authentication token
43
- def apply! a_hash, opts = {}
44
- # fetch the access token there is currently not one, or if the client
45
- # has expired
46
- fetch_access_token! opts if needs_access_token?
47
- a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}"
48
- end
49
-
50
- # Returns a clone of a_hash updated with the authentication token
51
- def apply a_hash, opts = {}
52
- a_copy = a_hash.clone
53
- apply! a_copy, opts
54
- a_copy
55
- end
56
-
57
- # Returns a reference to the #apply method, suitable for passing as
58
- # a closure
59
- def updater_proc
60
- proc { |a_hash, opts = {}| apply a_hash, opts }
61
- end
62
-
63
- def on_refresh &block
64
- @refresh_listeners = [] unless defined? @refresh_listeners
65
- @refresh_listeners << block
66
- end
67
-
68
39
  alias orig_fetch_access_token! fetch_access_token!
69
40
  def fetch_access_token! options = {}
70
41
  unless options[:connection]
@@ -78,13 +49,6 @@ module Signet
78
49
  info
79
50
  end
80
51
 
81
- def notify_refresh_listeners
82
- listeners = defined?(@refresh_listeners) ? @refresh_listeners : []
83
- listeners.each do |block|
84
- block.call self
85
- end
86
- end
87
-
88
52
  def build_default_connection
89
53
  if !defined?(@connection_info)
90
54
  nil
@@ -16,6 +16,6 @@ module Google
16
16
  # Module Auth provides classes that provide Google-specific authorization
17
17
  # used to access Google APIs.
18
18
  module Auth
19
- VERSION = "1.3.0".freeze
19
+ VERSION = "1.5.2".freeze
20
20
  end
21
21
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: googleauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Emiola
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-10-18 00:00:00.000000000 Z
11
+ date: 2023-04-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -134,17 +134,23 @@ files:
134
134
  - SECURITY.md
135
135
  - lib/googleauth.rb
136
136
  - lib/googleauth/application_default.rb
137
+ - lib/googleauth/base_client.rb
137
138
  - lib/googleauth/client_id.rb
138
139
  - lib/googleauth/compute_engine.rb
139
140
  - lib/googleauth/credentials.rb
140
141
  - lib/googleauth/credentials_loader.rb
141
142
  - lib/googleauth/default_credentials.rb
143
+ - lib/googleauth/external_account.rb
144
+ - lib/googleauth/external_account/aws_credentials.rb
145
+ - lib/googleauth/external_account/base_credentials.rb
146
+ - lib/googleauth/helpers/connection.rb
142
147
  - lib/googleauth/iam.rb
143
148
  - lib/googleauth/id_tokens.rb
144
149
  - lib/googleauth/id_tokens/errors.rb
145
150
  - lib/googleauth/id_tokens/key_sources.rb
146
151
  - lib/googleauth/id_tokens/verifier.rb
147
152
  - lib/googleauth/json_key_reader.rb
153
+ - lib/googleauth/oauth2/sts_client.rb
148
154
  - lib/googleauth/scope_util.rb
149
155
  - lib/googleauth/service_account.rb
150
156
  - lib/googleauth/signet.rb
@@ -177,7 +183,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
177
183
  - !ruby/object:Gem::Version
178
184
  version: '0'
179
185
  requirements: []
180
- rubygems_version: 3.3.14
186
+ rubygems_version: 3.4.2
181
187
  signing_key:
182
188
  specification_version: 4
183
189
  summary: Google Auth Library for Ruby