googleauth 1.3.0 → 1.5.0

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: 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: []