googleauth 1.3.0 → 1.5.2

Sign up to get free protection for your applications and to get access to all the features.
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