googleauth 1.3.0 → 1.5.1

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: 553d9c5927ca82c62dafc3a90529029ffd7813d99a9ae4ae146ae27370beb631
4
+ data.tar.gz: 55b33ba8e1ea2cf72f1f0c6c3c356ca825a4d575d8ac5665dc038118fc635198
5
5
  SHA512:
6
- metadata.gz: 73f52ffce21a05e15102b54aabbcb3cb199d32e9caf318b125b48b6caeddc01f77c3de4ea09513b0b1e9e503c912e55adf5864b4295b86af0620aa0c7df25df4
7
- data.tar.gz: 7ec107faa35d72aa1fd8e79b86b30df9acf061ce86ac52641bc12b69391f0b4f2adde8021b908327e379dee65c1e2ed7ed1b203e629ca1f4d25a988e80c31eb2
6
+ metadata.gz: 73a897d4813f5f016b252a299d126535fa32f017aa40ac7ac67200bef1332696298e2e082c279c3b8b850ca1d93d46f8a56f75d5c3ab408c33db391572944af7
7
+ data.tar.gz: b0d137864100e7e16ae1bac0b22ca7b2b09b5a9724692ed794170dbd712bc16b6ef00ad0913c47620d6df3e7324ce5a54484e2e4f9dfe06f999ebbeffd3c6007
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Release History
2
2
 
3
+ ### 1.5.1 (2023-04-10)
4
+
5
+ #### Bug Fixes
6
+
7
+ * Remove external account config validation ([#427](https://github.com/googleapis/google-auth-library-ruby/issues/427))
8
+
9
+ ### 1.5.0 (2023-03-21)
10
+
11
+ #### Features
12
+
13
+ * Add support for AWS Workload Identity Federation ([#418](https://github.com/googleapis/google-auth-library-ruby/issues/418))
14
+
15
+ ### 1.4.0 (2022-12-14)
16
+
17
+ #### Features
18
+
19
+ * make new_jwt_token public in order to fetch raw token directly ([#405](https://github.com/googleapis/google-auth-library-ruby/issues/405))
20
+
3
21
  ### 1.3.0 (2022-10-18)
4
22
 
5
23
  #### 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,365 @@
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 = @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
+ end
50
+
51
+ # Retrieves the subject token using the credential_source object.
52
+ # The subject token is a serialized [AWS GetCallerIdentity signed request](
53
+ # https://cloud.google.com/iam/docs/access-resources-aws#exchange-token).
54
+ #
55
+ # The logic is summarized as:
56
+ #
57
+ # Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION
58
+ # environment variable or from the AWS metadata server availability-zone
59
+ # if not found in the environment variable.
60
+ #
61
+ # Check AWS credentials in environment variables. If not found, retrieve
62
+ # from the AWS metadata server security-credentials endpoint.
63
+ #
64
+ # When retrieving AWS credentials from the metadata server
65
+ # security-credentials endpoint, the AWS role needs to be determined by
66
+ # calling the security-credentials endpoint without any argument. Then the
67
+ # 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
72
+ # signed request. 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 get_aws_resource url, name, data: nil, headers: {}
108
+ begin
109
+ unless [nil, url].include? @imdsv2_session_token_url
110
+ headers["x-aws-ec2-metadata-token"] = get_aws_resource(
111
+ @imdsv2_session_token_url,
112
+ "Session Token",
113
+ headers: { "x-aws-ec2-metadata-token-ttl-seconds": "300" }
114
+ ).body
115
+ end
116
+
117
+ response = if data
118
+ headers["Content-Type"] = "application/json"
119
+ connection.post url, data, headers
120
+ else
121
+ connection.get url, nil, headers
122
+ end
123
+
124
+ raise Faraday::Error unless response.success?
125
+ response
126
+ rescue Faraday::Error
127
+ raise "Failed to retrieve AWS #{name}."
128
+ end
129
+ end
130
+
131
+ def uri_escape string
132
+ if string.nil?
133
+ nil
134
+ else
135
+ CGI.escape(string.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~")
136
+ end
137
+ end
138
+
139
+ # Retrieves the AWS security credentials required for signing AWS
140
+ # requests from either the AWS security credentials environment variables
141
+ # or from the AWS metadata server.
142
+ def fetch_security_credentials
143
+ env_aws_access_key_id = ENV[CredentialsLoader::AWS_ACCESS_KEY_ID_VAR]
144
+ env_aws_secret_access_key = ENV[CredentialsLoader::AWS_SECRET_ACCESS_KEY_VAR]
145
+ # This is normally not available for permanent credentials.
146
+ env_aws_session_token = ENV[CredentialsLoader::AWS_SESSION_TOKEN_VAR]
147
+
148
+ if env_aws_access_key_id && env_aws_secret_access_key
149
+ return {
150
+ access_key_id: env_aws_access_key_id,
151
+ secret_access_key: env_aws_secret_access_key,
152
+ session_token: env_aws_session_token
153
+ }
154
+ end
155
+
156
+ role_name = fetch_metadata_role_name
157
+ credentials = fetch_metadata_security_credentials role_name
158
+
159
+ {
160
+ access_key_id: credentials["AccessKeyId"],
161
+ secret_access_key: credentials["SecretAccessKey"],
162
+ session_token: credentials["Token"]
163
+ }
164
+ end
165
+
166
+ # Retrieves the AWS role currently attached to the current AWS
167
+ # workload by querying the AWS metadata server. This is needed for the
168
+ # AWS metadata server security credentials endpoint in order to retrieve
169
+ # the AWS security credentials needed to sign requests to AWS APIs.
170
+ def fetch_metadata_role_name
171
+ unless @credential_verification_url
172
+ raise "Unable to determine the AWS metadata server security credentials endpoint"
173
+ end
174
+
175
+ get_aws_resource(@credential_verification_url, "IAM Role").body
176
+ end
177
+
178
+ # Retrieves the AWS security credentials required for signing AWS
179
+ # requests from the AWS metadata server.
180
+ def fetch_metadata_security_credentials role_name
181
+ response = get_aws_resource "#{@credential_verification_url}/#{role_name}", "credentials"
182
+ MultiJson.load response.body
183
+ end
184
+
185
+ def region
186
+ @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR]
187
+
188
+ unless @region
189
+ raise "region_url or region must be set for external account credentials" unless @region_url
190
+
191
+ @region ||= get_aws_resource(@region_url, "region").body[0..-2]
192
+ end
193
+
194
+ @region
195
+ end
196
+ end
197
+
198
+ # Implements an AWS request signer based on the AWS Signature Version 4 signing process.
199
+ # https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
200
+ class AwsRequestSigner
201
+ # Instantiates an AWS request signer used to compute authenticated signed
202
+ # requests to AWS APIs based on the AWS Signature Version 4 signing process.
203
+ #
204
+ # @param [string] region_name
205
+ # The AWS region to use.
206
+ def initialize region_name
207
+ @region_name = region_name
208
+ end
209
+
210
+ # Generates the signed request for the provided HTTP request for calling
211
+ # an AWS API. This follows the steps described at:
212
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
213
+ #
214
+ # @param [Hash[string, string]] aws_security_credentials
215
+ # A dictionary containing the AWS security credentials.
216
+ # @param [string] url
217
+ # The AWS service URL containing the canonical URI and query string.
218
+ # @param [string] method
219
+ # The HTTP method used to call this API.
220
+ #
221
+ # @return [hash{string => string}]
222
+ # The AWS signed request dictionary object.
223
+ #
224
+ def generate_signed_request aws_credentials, original_request
225
+ uri = Addressable::URI.parse original_request[:url]
226
+ raise "Invalid AWS service URL" unless uri.hostname && uri.scheme == "https"
227
+ service_name = uri.host.split(".").first
228
+
229
+ datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ"
230
+ date = datetime[0, 8]
231
+
232
+ headers = aws_headers aws_credentials, original_request, datetime
233
+
234
+ request_payload = original_request[:data] || ""
235
+ content_sha256 = sha256_hexdigest request_payload
236
+
237
+ canonical_req = canonical_request original_request[:method], uri, headers, content_sha256
238
+ sts = string_to_sign datetime, canonical_req, service_name
239
+
240
+ # Authorization header requires everything else to be properly setup in order to be properly
241
+ # calculated.
242
+ headers["Authorization"] = build_authorization_header headers, sts, aws_credentials, service_name, date
243
+
244
+ {
245
+ url: uri.to_s,
246
+ headers: headers,
247
+ method: original_request[:method],
248
+ data: (request_payload unless request_payload.empty?)
249
+ }.compact
250
+ end
251
+
252
+ private
253
+
254
+ def aws_headers aws_credentials, original_request, datetime
255
+ uri = Addressable::URI.parse original_request[:url]
256
+ temp_headers = original_request[:headers] || {}
257
+ headers = {}
258
+ temp_headers.each_key { |k| headers[k.to_s] = temp_headers[k] }
259
+ headers["host"] = uri.host
260
+ headers["x-amz-date"] = datetime
261
+ headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token]
262
+ headers
263
+ end
264
+
265
+ def build_authorization_header headers, sts, aws_credentials, service_name, date
266
+ [
267
+ "AWS4-HMAC-SHA256",
268
+ "Credential=#{credential aws_credentials[:access_key_id], date, service_name},",
269
+ "SignedHeaders=#{headers.keys.sort.join ';'},",
270
+ "Signature=#{signature aws_credentials[:secret_access_key], date, sts, service_name}"
271
+ ].join(" ")
272
+ end
273
+
274
+ def signature secret_access_key, date, string_to_sign, service
275
+ k_date = hmac "AWS4#{secret_access_key}", date
276
+ k_region = hmac k_date, @region_name
277
+ k_service = hmac k_region, service
278
+ k_credentials = hmac k_service, "aws4_request"
279
+
280
+ hexhmac k_credentials, string_to_sign
281
+ end
282
+
283
+ def hmac key, value
284
+ OpenSSL::HMAC.digest OpenSSL::Digest.new("sha256"), key, value
285
+ end
286
+
287
+ def hexhmac key, value
288
+ OpenSSL::HMAC.hexdigest OpenSSL::Digest.new("sha256"), key, value
289
+ end
290
+
291
+ def credential access_key_id, date, service
292
+ "#{access_key_id}/#{credential_scope date, service}"
293
+ end
294
+
295
+ def credential_scope date, service
296
+ [
297
+ date,
298
+ @region_name,
299
+ service,
300
+ "aws4_request"
301
+ ].join("/")
302
+ end
303
+
304
+ def string_to_sign datetime, canonical_request, service
305
+ [
306
+ "AWS4-HMAC-SHA256",
307
+ datetime,
308
+ credential_scope(datetime[0, 8], service),
309
+ sha256_hexdigest(canonical_request)
310
+ ].join("\n")
311
+ end
312
+
313
+ def host uri
314
+ # Handles known and unknown URI schemes; default_port nil when unknown.
315
+ if uri.default_port == uri.port
316
+ uri.host
317
+ else
318
+ "#{uri.host}:#{uri.port}"
319
+ end
320
+ end
321
+
322
+ def canonical_request http_method, uri, headers, content_sha256
323
+ headers = headers.sort_by(&:first) # transforms to a sorted array of [key, value]
324
+
325
+ [
326
+ http_method,
327
+ uri.path.empty? ? "/" : uri.path,
328
+ build_canonical_querystring(uri.query || ""),
329
+ headers.map { |k, v| "#{k}:#{v}\n" }.join, # Canonical headers
330
+ headers.map(&:first).join(";"), # Signed headers
331
+ content_sha256
332
+ ].join("\n")
333
+ end
334
+
335
+ def sha256_hexdigest string
336
+ OpenSSL::Digest::SHA256.hexdigest string
337
+ end
338
+
339
+ # Generates the canonical query string given a raw query string.
340
+ # Logic is based on
341
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
342
+ # Code is from the AWS SDK for Ruby
343
+ # https://github.com/aws/aws-sdk-ruby/blob/0ac3d0a393ed216290bfb5f0383380376f6fb1f1/gems/aws-sigv4/lib/aws-sigv4/signer.rb#L532
344
+ def build_canonical_querystring query
345
+ params = query.split "&"
346
+ params = params.map { |p| p.include?("=") ? p : "#{p}=" }
347
+
348
+ params.each.with_index.sort do |(a, a_offset), (b, b_offset)|
349
+ a_name, a_value = a.split "="
350
+ b_name, b_value = b.split "="
351
+ if a_name == b_name
352
+ if a_value == b_value
353
+ a_offset <=> b_offset
354
+ else
355
+ a_value <=> b_value
356
+ end
357
+ else
358
+ a_name <=> b_name
359
+ end
360
+ end.map(&:first).join("&")
361
+ end
362
+ end
363
+ end
364
+ end
365
+ 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,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.1".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.1
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-10 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