googleauth 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0bc48c47d78d7ec955a2a5557fc8f1cff502a28dd1e18c5af3fc566be5743171
4
- data.tar.gz: 220a8fed81a73d5bc93a2fca2951a749b9469cb769a198cf13564ad7f714ac90
3
+ metadata.gz: 42581efbf67b1cafdcdcd18cd227d22b5d456d695f3b5cfaa089f4121c17bce2
4
+ data.tar.gz: 968618cfa8048d5c246c83acc769b19c3f258958e21810481464d1b297d651bc
5
5
  SHA512:
6
- metadata.gz: 73f52ffce21a05e15102b54aabbcb3cb199d32e9caf318b125b48b6caeddc01f77c3de4ea09513b0b1e9e503c912e55adf5864b4295b86af0620aa0c7df25df4
7
- data.tar.gz: 7ec107faa35d72aa1fd8e79b86b30df9acf061ce86ac52641bc12b69391f0b4f2adde8021b908327e379dee65c1e2ed7ed1b203e629ca1f4d25a988e80c31eb2
6
+ metadata.gz: 392dc977400f0229fd416cdcd2d5ed60fa0a8592926c622fed3dea2ba6ba1e083169d978c7a48d4e895014909352196fca56ac8c21df1f0b3694e55439cfedb2
7
+ data.tar.gz: 1a33a41171c9963196f833fa83702d61af6e335b677a229bf0f93316e7f0272f9b211f00fc7e180c4f07e1e7dd5ffbe29b484f5cd72578a49096046c6d017fc4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Release History
2
2
 
3
+ ### 1.5.0 (2023-03-21)
4
+
5
+ #### Features
6
+
7
+ * Add support for AWS Workload Identity Federation ([#418](https://github.com/googleapis/google-auth-library-ruby/issues/418))
8
+
9
+ ### 1.4.0 (2022-12-14)
10
+
11
+ #### Features
12
+
13
+ * make new_jwt_token public in order to fetch raw token directly ([#405](https://github.com/googleapis/google-auth-library-ruby/issues/405))
14
+
3
15
  ### 1.3.0 (2022-10-18)
4
16
 
5
17
  #### Features
@@ -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,373 @@
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
20
+ # used to access Google APIs.
21
+ module Auth
22
+ # Authenticates requests using External Account credentials, such
23
+ # as those provided by the AWS provider.
24
+ module ExternalAccount
25
+ # This module handles the retrieval of credentials from Google Cloud
26
+ # by utilizing the AWS EC2 metadata service and then exchanging the
27
+ # credentials for a short-lived Google Cloud access token.
28
+ class AwsCredentials
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 = validate_metadata_server @credential_source["region_url"], "region_url"
42
+ @credential_verification_url = validate_metadata_server @credential_source["url"], "url"
43
+ @regional_cred_verification_url = @credential_source["regional_cred_verification_url"]
44
+ @imdsv2_session_token_url = validate_metadata_server @credential_source["imdsv2_session_token_url"],
45
+ "imdsv2_session_token_url"
46
+
47
+ # These will be lazily loaded when needed, or will raise an error if not provided
48
+ @region = nil
49
+ @request_signer = nil
50
+ end
51
+
52
+ # Retrieves the subject token using the credential_source object.
53
+ # The subject token is a serialized [AWS GetCallerIdentity signed request](
54
+ # https://cloud.google.com/iam/docs/access-resources-aws#exchange-token).
55
+ #
56
+ # The logic is summarized as:
57
+ #
58
+ # Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION
59
+ # environment variable or from the AWS metadata server availability-zone
60
+ # if not found in the environment variable.
61
+ #
62
+ # Check AWS credentials in environment variables. If not found, retrieve
63
+ # from the AWS metadata server security-credentials endpoint.
64
+ #
65
+ # When retrieving AWS credentials from the metadata server
66
+ # security-credentials endpoint, the AWS role needs to be determined by
67
+ # calling the security-credentials endpoint without any argument. Then the
68
+ # credentials can be retrieved via: security-credentials/role_name
69
+ #
70
+ # Generate the signed request to AWS STS GetCallerIdentity action.
71
+ #
72
+ # Inject x-goog-cloud-target-resource into header and serialize the
73
+ # signed request. This will be the subject-token to pass to GCP STS.
74
+ #
75
+ # @return [string] The retrieved subject token.
76
+ #
77
+ def retrieve_subject_token!
78
+ if @request_signer.nil?
79
+ @region = region
80
+ @request_signer = AwsRequestSigner.new @region
81
+ end
82
+
83
+ request = {
84
+ method: "POST",
85
+ url: @regional_cred_verification_url.sub("{region}", @region)
86
+ }
87
+
88
+ request_options = @request_signer.generate_signed_request fetch_security_credentials, request
89
+
90
+ request_headers = request_options[:headers]
91
+ request_headers["x-goog-cloud-target-resource"] = @audience
92
+
93
+ aws_signed_request = {
94
+ headers: [],
95
+ method: request_options[:method],
96
+ url: request_options[:url]
97
+ }
98
+
99
+ aws_signed_request[:headers] = request_headers.keys.sort.map do |key|
100
+ { key: key, value: request_headers[key] }
101
+ end
102
+
103
+ uri_escape aws_signed_request.to_json
104
+ end
105
+
106
+ private
107
+
108
+ def validate_metadata_server url, name
109
+ return nil if url.nil?
110
+ host = URI(url).host
111
+ raise "Invalid host #{host} for #{name}." unless ["169.254.169.254", "[fd00:ec2::254]"].include? host
112
+ url
113
+ end
114
+
115
+ def get_aws_resource url, name, data: nil, headers: {}
116
+ begin
117
+ unless [nil, url].include? @imdsv2_session_token_url
118
+ headers["x-aws-ec2-metadata-token"] = get_aws_resource(
119
+ @imdsv2_session_token_url,
120
+ "Session Token",
121
+ headers: { "x-aws-ec2-metadata-token-ttl-seconds": "300" }
122
+ ).body
123
+ end
124
+
125
+ response = if data
126
+ headers["Content-Type"] = "application/json"
127
+ connection.post url, data, headers
128
+ else
129
+ connection.get url, nil, headers
130
+ end
131
+
132
+ raise Faraday::Error unless response.success?
133
+ response
134
+ rescue Faraday::Error
135
+ raise "Failed to retrieve AWS #{name}."
136
+ end
137
+ end
138
+
139
+ def uri_escape string
140
+ if string.nil?
141
+ nil
142
+ else
143
+ CGI.escape(string.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~")
144
+ end
145
+ end
146
+
147
+ # Retrieves the AWS security credentials required for signing AWS
148
+ # requests from either the AWS security credentials environment variables
149
+ # or from the AWS metadata server.
150
+ def fetch_security_credentials
151
+ env_aws_access_key_id = ENV[CredentialsLoader::AWS_ACCESS_KEY_ID_VAR]
152
+ env_aws_secret_access_key = ENV[CredentialsLoader::AWS_SECRET_ACCESS_KEY_VAR]
153
+ # This is normally not available for permanent credentials.
154
+ env_aws_session_token = ENV[CredentialsLoader::AWS_SESSION_TOKEN_VAR]
155
+
156
+ if env_aws_access_key_id && env_aws_secret_access_key
157
+ return {
158
+ access_key_id: env_aws_access_key_id,
159
+ secret_access_key: env_aws_secret_access_key,
160
+ session_token: env_aws_session_token
161
+ }
162
+ end
163
+
164
+ role_name = fetch_metadata_role_name
165
+ credentials = fetch_metadata_security_credentials role_name
166
+
167
+ {
168
+ access_key_id: credentials["AccessKeyId"],
169
+ secret_access_key: credentials["SecretAccessKey"],
170
+ session_token: credentials["Token"]
171
+ }
172
+ end
173
+
174
+ # Retrieves the AWS role currently attached to the current AWS
175
+ # workload by querying the AWS metadata server. This is needed for the
176
+ # AWS metadata server security credentials endpoint in order to retrieve
177
+ # the AWS security credentials needed to sign requests to AWS APIs.
178
+ def fetch_metadata_role_name
179
+ unless @credential_verification_url
180
+ raise "Unable to determine the AWS metadata server security credentials endpoint"
181
+ end
182
+
183
+ get_aws_resource(@credential_verification_url, "IAM Role").body
184
+ end
185
+
186
+ # Retrieves the AWS security credentials required for signing AWS
187
+ # requests from the AWS metadata server.
188
+ def fetch_metadata_security_credentials role_name
189
+ response = get_aws_resource "#{@credential_verification_url}/#{role_name}", "credentials"
190
+ MultiJson.load response.body
191
+ end
192
+
193
+ def region
194
+ @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR]
195
+
196
+ unless @region
197
+ raise "region_url or region must be set for external account credentials" unless @region_url
198
+
199
+ @region ||= get_aws_resource(@region_url, "region").body[0..-2]
200
+ end
201
+
202
+ @region
203
+ end
204
+ end
205
+
206
+ # Implements an AWS request signer based on the AWS Signature Version 4 signing process.
207
+ # https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
208
+ class AwsRequestSigner
209
+ # Instantiates an AWS request signer used to compute authenticated signed
210
+ # requests to AWS APIs based on the AWS Signature Version 4 signing process.
211
+ #
212
+ # @param [string] region_name
213
+ # The AWS region to use.
214
+ def initialize region_name
215
+ @region_name = region_name
216
+ end
217
+
218
+ # Generates the signed request for the provided HTTP request for calling
219
+ # an AWS API. This follows the steps described at:
220
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
221
+ #
222
+ # @param [Hash[string, string]] aws_security_credentials
223
+ # A dictionary containing the AWS security credentials.
224
+ # @param [string] url
225
+ # The AWS service URL containing the canonical URI and query string.
226
+ # @param [string] method
227
+ # The HTTP method used to call this API.
228
+ #
229
+ # @return [hash{string => string}]
230
+ # The AWS signed request dictionary object.
231
+ #
232
+ def generate_signed_request aws_credentials, original_request
233
+ uri = Addressable::URI.parse original_request[:url]
234
+ raise "Invalid AWS service URL" unless uri.hostname && uri.scheme == "https"
235
+ service_name = uri.host.split(".").first
236
+
237
+ datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ"
238
+ date = datetime[0, 8]
239
+
240
+ headers = aws_headers aws_credentials, original_request, datetime
241
+
242
+ request_payload = original_request[:data] || ""
243
+ content_sha256 = sha256_hexdigest request_payload
244
+
245
+ canonical_req = canonical_request original_request[:method], uri, headers, content_sha256
246
+ sts = string_to_sign datetime, canonical_req, service_name
247
+
248
+ # Authorization header requires everything else to be properly setup in order to be properly
249
+ # calculated.
250
+ headers["Authorization"] = build_authorization_header headers, sts, aws_credentials, service_name, date
251
+
252
+ {
253
+ url: uri.to_s,
254
+ headers: headers,
255
+ method: original_request[:method],
256
+ data: (request_payload unless request_payload.empty?)
257
+ }.compact
258
+ end
259
+
260
+ private
261
+
262
+ def aws_headers aws_credentials, original_request, datetime
263
+ uri = Addressable::URI.parse original_request[:url]
264
+ temp_headers = original_request[:headers] || {}
265
+ headers = {}
266
+ temp_headers.each_key { |k| headers[k.to_s] = temp_headers[k] }
267
+ headers["host"] = uri.host
268
+ headers["x-amz-date"] = datetime
269
+ headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token]
270
+ headers
271
+ end
272
+
273
+ def build_authorization_header headers, sts, aws_credentials, service_name, date
274
+ [
275
+ "AWS4-HMAC-SHA256",
276
+ "Credential=#{credential aws_credentials[:access_key_id], date, service_name},",
277
+ "SignedHeaders=#{headers.keys.sort.join ';'},",
278
+ "Signature=#{signature aws_credentials[:secret_access_key], date, sts, service_name}"
279
+ ].join(" ")
280
+ end
281
+
282
+ def signature secret_access_key, date, string_to_sign, service
283
+ k_date = hmac "AWS4#{secret_access_key}", date
284
+ k_region = hmac k_date, @region_name
285
+ k_service = hmac k_region, service
286
+ k_credentials = hmac k_service, "aws4_request"
287
+
288
+ hexhmac k_credentials, string_to_sign
289
+ end
290
+
291
+ def hmac key, value
292
+ OpenSSL::HMAC.digest OpenSSL::Digest.new("sha256"), key, value
293
+ end
294
+
295
+ def hexhmac key, value
296
+ OpenSSL::HMAC.hexdigest OpenSSL::Digest.new("sha256"), key, value
297
+ end
298
+
299
+ def credential access_key_id, date, service
300
+ "#{access_key_id}/#{credential_scope date, service}"
301
+ end
302
+
303
+ def credential_scope date, service
304
+ [
305
+ date,
306
+ @region_name,
307
+ service,
308
+ "aws4_request"
309
+ ].join("/")
310
+ end
311
+
312
+ def string_to_sign datetime, canonical_request, service
313
+ [
314
+ "AWS4-HMAC-SHA256",
315
+ datetime,
316
+ credential_scope(datetime[0, 8], service),
317
+ sha256_hexdigest(canonical_request)
318
+ ].join("\n")
319
+ end
320
+
321
+ def host uri
322
+ # Handles known and unknown URI schemes; default_port nil when unknown.
323
+ if uri.default_port == uri.port
324
+ uri.host
325
+ else
326
+ "#{uri.host}:#{uri.port}"
327
+ end
328
+ end
329
+
330
+ def canonical_request http_method, uri, headers, content_sha256
331
+ headers = headers.sort_by(&:first) # transforms to a sorted array of [key, value]
332
+
333
+ [
334
+ http_method,
335
+ uri.path.empty? ? "/" : uri.path,
336
+ build_canonical_querystring(uri.query || ""),
337
+ headers.map { |k, v| "#{k}:#{v}\n" }.join, # Canonical headers
338
+ headers.map(&:first).join(";"), # Signed headers
339
+ content_sha256
340
+ ].join("\n")
341
+ end
342
+
343
+ def sha256_hexdigest string
344
+ OpenSSL::Digest::SHA256.hexdigest string
345
+ end
346
+
347
+ # Generates the canonical query string given a raw query string.
348
+ # Logic is based on
349
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
350
+ # Code is from the AWS SDK for Ruby
351
+ # https://github.com/aws/aws-sdk-ruby/blob/0ac3d0a393ed216290bfb5f0383380376f6fb1f1/gems/aws-sigv4/lib/aws-sigv4/signer.rb#L532
352
+ def build_canonical_querystring query
353
+ params = query.split "&"
354
+ params = params.map { |p| p.include?("=") ? p : "#{p}=" }
355
+
356
+ params.each.with_index.sort do |(a, a_offset), (b, b_offset)|
357
+ a_name, a_value = a.split "="
358
+ b_name, b_value = b.split "="
359
+ if a_name == b_name
360
+ if a_value == b_value
361
+ a_offset <=> b_offset
362
+ else
363
+ a_value <=> b_value
364
+ end
365
+ else
366
+ a_name <=> b_name
367
+ end
368
+ end.map(&:first).join("&")
369
+ end
370
+ end
371
+ end
372
+ end
373
+ 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
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,111 @@
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
+ TOKEN_URL_PATTERNS = [
34
+ /^[^.\s\/\\]+\.sts(?:\.mtls)?\.googleapis\.com$/,
35
+ /^sts(?:\.mtls)?\.googleapis\.com$/,
36
+ /^sts\.[^.\s\/\\]+(?:\.mtls)?\.googleapis\.com$/,
37
+ /^[^.\s\/\\]+-sts(?:\.mtls)?\.googleapis\.com$/,
38
+ /^sts-[^.\s\/\\]+\.p(?:\.mtls)?\.googleapis\.com$/
39
+ ].freeze
40
+
41
+ SERVICE_ACCOUNT_IMPERSONATION_URL_PATTERNS = [
42
+ /^[^.\s\/\\]+\.iamcredentials\.googleapis\.com$/.freeze,
43
+ /^iamcredentials\.googleapis\.com$/.freeze,
44
+ /^iamcredentials\.[^.\s\/\\]+\.googleapis\.com$/.freeze,
45
+ /^[^.\s\/\\]+-iamcredentials\.googleapis\.com$/.freeze,
46
+ /^iamcredentials-[^.\s\/\\]+\.p\.googleapis\.com$/.freeze
47
+ ].freeze
48
+
49
+ # Create a ExternalAccount::Credentials
50
+ #
51
+ # @param json_key_io [IO] an IO from which the JSON key can be read
52
+ # @param scope [String,Array,nil] the scope(s) to access
53
+ def self.make_creds options = {}
54
+ json_key_io, scope = options.values_at :json_key_io, :scope
55
+
56
+ raise "A json file is required for external account credentials." unless json_key_io
57
+ user_creds = read_json_key json_key_io
58
+
59
+ raise "The provided token URL is invalid." unless is_token_url_valid? user_creds["token_url"]
60
+ unless is_service_account_impersonation_url_valid? user_creds["service_account_impersonation_url"]
61
+ raise "The provided service account impersonation url is invalid."
62
+ end
63
+
64
+ # TODO: check for other External Account Credential types. Currently only AWS is supported.
65
+ raise AWS_SUBJECT_TOKEN_INVALID unless user_creds["subject_token_type"] == AWS_SUBJECT_TOKEN_TYPE
66
+
67
+ Google::Auth::ExternalAccount::AwsCredentials.new(
68
+ audience: user_creds["audience"],
69
+ scope: scope,
70
+ subject_token_type: user_creds["subject_token_type"],
71
+ token_url: user_creds["token_url"],
72
+ credential_source: user_creds["credential_source"],
73
+ service_account_impersonation_url: user_creds["service_account_impersonation_url"]
74
+ )
75
+ end
76
+
77
+ # Reads the required fields from the JSON.
78
+ def self.read_json_key json_key_io
79
+ json_key = MultiJson.load json_key_io.read
80
+ wanted = [
81
+ "audience", "subject_token_type", "token_url", "credential_source"
82
+ ]
83
+ wanted.each do |key|
84
+ raise "the json is missing the #{key} field" unless json_key.key? key
85
+ end
86
+ json_key
87
+ end
88
+
89
+ def self.is_valid_url? url, valid_hostnames
90
+ begin
91
+ uri = URI(url)
92
+ rescue URI::InvalidURIError, ArgumentError
93
+ return false
94
+ end
95
+
96
+ return false unless uri.scheme == "https"
97
+
98
+ valid_hostnames.any? { |hostname| hostname =~ uri.host }
99
+ end
100
+
101
+ def self.is_token_url_valid? url
102
+ is_valid_url? url, TOKEN_URL_PATTERNS
103
+ end
104
+
105
+ def self.is_service_account_impersonation_url_valid? url
106
+ !url or is_valid_url? url, SERVICE_ACCOUNT_IMPERSONATION_URL_PATTERNS
107
+ end
108
+ end
109
+ end
110
+ end
111
+ 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.0".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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Emiola
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-10-18 00:00:00.000000000 Z
11
+ date: 2023-03-22 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
@@ -162,7 +168,7 @@ metadata:
162
168
  changelog_uri: https://github.com/googleapis/google-auth-library-ruby/blob/main/CHANGELOG.md
163
169
  source_code_uri: https://github.com/googleapis/google-auth-library-ruby
164
170
  bug_tracker_uri: https://github.com/googleapis/google-auth-library-ruby/issues
165
- post_install_message:
171
+ post_install_message:
166
172
  rdoc_options: []
167
173
  require_paths:
168
174
  - lib
@@ -177,8 +183,8 @@ 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
181
- signing_key:
186
+ rubygems_version: 3.3.26
187
+ signing_key:
182
188
  specification_version: 4
183
189
  summary: Google Auth Library for Ruby
184
190
  test_files: []