googleauth 1.3.0 → 1.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,378 @@
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
+ require "googleauth/external_account/external_account_utils"
18
+
19
+ module Google
20
+ # Module Auth provides classes that provide Google-specific authorization used to access Google APIs.
21
+ module Auth
22
+ # Authenticates requests using External Account credentials, such as those provided by the AWS provider.
23
+ module ExternalAccount
24
+ # This module handles the retrieval of credentials from Google Cloud by utilizing the AWS EC2 metadata service and
25
+ # then exchanging the credentials for a short-lived Google Cloud access token.
26
+ class AwsCredentials
27
+ # Constant for imdsv2 session token expiration in seconds
28
+ IMDSV2_TOKEN_EXPIRATION_IN_SECONDS = 300
29
+
30
+ include Google::Auth::ExternalAccount::BaseCredentials
31
+ include Google::Auth::ExternalAccount::ExternalAccountUtils
32
+ extend CredentialsLoader
33
+
34
+ # Will always be nil, but method still gets used.
35
+ attr_reader :client_id
36
+
37
+ def initialize options = {}
38
+ base_setup options
39
+
40
+ @audience = options[:audience]
41
+ @credential_source = options[:credential_source] || {}
42
+ @environment_id = @credential_source[:environment_id]
43
+ @region_url = @credential_source[:region_url]
44
+ @credential_verification_url = @credential_source[:url]
45
+ @regional_cred_verification_url = @credential_source[:regional_cred_verification_url]
46
+ @imdsv2_session_token_url = @credential_source[:imdsv2_session_token_url]
47
+
48
+ # These will be lazily loaded when needed, or will raise an error if not provided
49
+ @region = nil
50
+ @request_signer = nil
51
+ @imdsv2_session_token = nil
52
+ @imdsv2_session_token_expiry = nil
53
+ end
54
+
55
+ # Retrieves the subject token using the credential_source object.
56
+ # The subject token is a serialized [AWS GetCallerIdentity signed request](
57
+ # https://cloud.google.com/iam/docs/access-resources-aws#exchange-token).
58
+ #
59
+ # The logic is summarized as:
60
+ #
61
+ # Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION environment variable or from the AWS
62
+ # metadata server availability-zone if not found in the environment variable.
63
+ #
64
+ # Check AWS credentials in environment variables. If not found, retrieve from the AWS metadata server
65
+ # security-credentials endpoint.
66
+ #
67
+ # When retrieving AWS credentials from the metadata server security-credentials endpoint, the AWS role needs to
68
+ # be determined by # calling the security-credentials endpoint without any argument.
69
+ # Then the credentials can be retrieved via: security-credentials/role_name
70
+ #
71
+ # Generate the signed request to AWS STS GetCallerIdentity action.
72
+ #
73
+ # Inject x-goog-cloud-target-resource into header and serialize the signed request.
74
+ # This will be the subject-token to pass to GCP STS.
75
+ #
76
+ # @return [string] The retrieved subject token.
77
+ #
78
+ def retrieve_subject_token!
79
+ if @request_signer.nil?
80
+ @region = region
81
+ @request_signer = AwsRequestSigner.new @region
82
+ end
83
+
84
+ request = {
85
+ method: "POST",
86
+ url: @regional_cred_verification_url.sub("{region}", @region)
87
+ }
88
+
89
+ request_options = @request_signer.generate_signed_request fetch_security_credentials, request
90
+
91
+ request_headers = request_options[:headers]
92
+ request_headers["x-goog-cloud-target-resource"] = @audience
93
+
94
+ aws_signed_request = {
95
+ headers: [],
96
+ method: request_options[:method],
97
+ url: request_options[:url]
98
+ }
99
+
100
+ aws_signed_request[:headers] = request_headers.keys.sort.map do |key|
101
+ { key: key, value: request_headers[key] }
102
+ end
103
+
104
+ uri_escape aws_signed_request.to_json
105
+ end
106
+
107
+ private
108
+
109
+ def imdsv2_session_token
110
+ return @imdsv2_session_token unless imdsv2_session_token_invalid?
111
+ raise "IMDSV2 token url must be provided" if @imdsv2_session_token_url.nil?
112
+ begin
113
+ response = connection.put @imdsv2_session_token_url do |req|
114
+ req.headers["x-aws-ec2-metadata-token-ttl-seconds"] = IMDSV2_TOKEN_EXPIRATION_IN_SECONDS.to_s
115
+ end
116
+ rescue Faraday::Error => e
117
+ raise "Fetching AWS IMDSV2 token error: #{e}"
118
+ end
119
+ raise Faraday::Error unless response.success?
120
+ @imdsv2_session_token = response.body
121
+ @imdsv2_session_token_expiry = Time.now + IMDSV2_TOKEN_EXPIRATION_IN_SECONDS
122
+ @imdsv2_session_token
123
+ end
124
+
125
+ def imdsv2_session_token_invalid?
126
+ return true if @imdsv2_session_token.nil?
127
+ @imdsv2_session_token_expiry.nil? || @imdsv2_session_token_expiry < Time.now
128
+ end
129
+
130
+ def get_aws_resource url, name, data: nil, headers: {}
131
+ begin
132
+ headers["x-aws-ec2-metadata-token"] = imdsv2_session_token
133
+ response = if data
134
+ headers["Content-Type"] = "application/json"
135
+ connection.post url, data, headers
136
+ else
137
+ connection.get url, nil, headers
138
+ end
139
+
140
+ raise Faraday::Error unless response.success?
141
+ response
142
+ rescue Faraday::Error
143
+ raise "Failed to retrieve AWS #{name}."
144
+ end
145
+ end
146
+
147
+ def uri_escape string
148
+ if string.nil?
149
+ nil
150
+ else
151
+ CGI.escape(string.encode("UTF-8")).gsub("+", "%20").gsub("%7E", "~")
152
+ end
153
+ end
154
+
155
+ # Retrieves the AWS security credentials required for signing AWS requests from either the AWS security
156
+ # credentials environment variables or from the AWS metadata server.
157
+ def fetch_security_credentials
158
+ env_aws_access_key_id = ENV[CredentialsLoader::AWS_ACCESS_KEY_ID_VAR]
159
+ env_aws_secret_access_key = ENV[CredentialsLoader::AWS_SECRET_ACCESS_KEY_VAR]
160
+ # This is normally not available for permanent credentials.
161
+ env_aws_session_token = ENV[CredentialsLoader::AWS_SESSION_TOKEN_VAR]
162
+
163
+ if env_aws_access_key_id && env_aws_secret_access_key
164
+ return {
165
+ access_key_id: env_aws_access_key_id,
166
+ secret_access_key: env_aws_secret_access_key,
167
+ session_token: env_aws_session_token
168
+ }
169
+ end
170
+
171
+ role_name = fetch_metadata_role_name
172
+ credentials = fetch_metadata_security_credentials role_name
173
+
174
+ {
175
+ access_key_id: credentials["AccessKeyId"],
176
+ secret_access_key: credentials["SecretAccessKey"],
177
+ session_token: credentials["Token"]
178
+ }
179
+ end
180
+
181
+ # Retrieves the AWS role currently attached to the current AWS workload by querying the AWS metadata server.
182
+ # This is needed for the AWS metadata server security credentials endpoint in order to retrieve the AWS security
183
+ # credentials needed to sign requests to AWS APIs.
184
+ def fetch_metadata_role_name
185
+ unless @credential_verification_url
186
+ raise "Unable to determine the AWS metadata server security credentials endpoint"
187
+ end
188
+
189
+ get_aws_resource(@credential_verification_url, "IAM Role").body
190
+ end
191
+
192
+ # Retrieves the AWS security credentials required for signing AWS requests from the AWS metadata server.
193
+ def fetch_metadata_security_credentials role_name
194
+ response = get_aws_resource "#{@credential_verification_url}/#{role_name}", "credentials"
195
+ MultiJson.load response.body
196
+ end
197
+
198
+ def region
199
+ @region = ENV[CredentialsLoader::AWS_REGION_VAR] || ENV[CredentialsLoader::AWS_DEFAULT_REGION_VAR]
200
+
201
+ unless @region
202
+ raise "region_url or region must be set for external account credentials" unless @region_url
203
+
204
+ @region ||= get_aws_resource(@region_url, "region").body[0..-2]
205
+ end
206
+
207
+ @region
208
+ end
209
+ end
210
+
211
+ # Implements an AWS request signer based on the AWS Signature Version 4 signing process.
212
+ # https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
213
+ class AwsRequestSigner
214
+ # Instantiates an AWS request signer used to compute authenticated signed requests to AWS APIs based on the AWS
215
+ # Signature Version 4 signing process.
216
+ #
217
+ # @param [string] region_name
218
+ # The AWS region to use.
219
+ def initialize region_name
220
+ @region_name = region_name
221
+ end
222
+
223
+ # Generates the signed request for the provided HTTP request for calling
224
+ # an AWS API. This follows the steps described at:
225
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
226
+ #
227
+ # @param [Hash[string, string]] aws_security_credentials
228
+ # A dictionary containing the AWS security credentials.
229
+ # @param [string] url
230
+ # The AWS service URL containing the canonical URI and query string.
231
+ # @param [string] method
232
+ # The HTTP method used to call this API.
233
+ #
234
+ # @return [hash{string => string}]
235
+ # The AWS signed request dictionary object.
236
+ #
237
+ def generate_signed_request aws_credentials, original_request
238
+ uri = Addressable::URI.parse original_request[:url]
239
+ raise "Invalid AWS service URL" unless uri.hostname && uri.scheme == "https"
240
+ service_name = uri.host.split(".").first
241
+
242
+ datetime = Time.now.utc.strftime "%Y%m%dT%H%M%SZ"
243
+ date = datetime[0, 8]
244
+
245
+ headers = aws_headers aws_credentials, original_request, datetime
246
+
247
+ request_payload = original_request[:data] || ""
248
+ content_sha256 = sha256_hexdigest request_payload
249
+
250
+ canonical_req = canonical_request original_request[:method], uri, headers, content_sha256
251
+ sts = string_to_sign datetime, canonical_req, service_name
252
+
253
+ # Authorization header requires everything else to be properly setup in order to be properly
254
+ # calculated.
255
+ headers["Authorization"] = build_authorization_header headers, sts, aws_credentials, service_name, date
256
+
257
+ {
258
+ url: uri.to_s,
259
+ headers: headers,
260
+ method: original_request[:method],
261
+ data: (request_payload unless request_payload.empty?)
262
+ }.compact
263
+ end
264
+
265
+ private
266
+
267
+ def aws_headers aws_credentials, original_request, datetime
268
+ uri = Addressable::URI.parse original_request[:url]
269
+ temp_headers = original_request[:headers] || {}
270
+ headers = {}
271
+ temp_headers.each_key { |k| headers[k.to_s] = temp_headers[k] }
272
+ headers["host"] = uri.host
273
+ headers["x-amz-date"] = datetime
274
+ headers["x-amz-security-token"] = aws_credentials[:session_token] if aws_credentials[:session_token]
275
+ headers
276
+ end
277
+
278
+ def build_authorization_header headers, sts, aws_credentials, service_name, date
279
+ [
280
+ "AWS4-HMAC-SHA256",
281
+ "Credential=#{credential aws_credentials[:access_key_id], date, service_name},",
282
+ "SignedHeaders=#{headers.keys.sort.join ';'},",
283
+ "Signature=#{signature aws_credentials[:secret_access_key], date, sts, service_name}"
284
+ ].join(" ")
285
+ end
286
+
287
+ def signature secret_access_key, date, string_to_sign, service
288
+ k_date = hmac "AWS4#{secret_access_key}", date
289
+ k_region = hmac k_date, @region_name
290
+ k_service = hmac k_region, service
291
+ k_credentials = hmac k_service, "aws4_request"
292
+
293
+ hexhmac k_credentials, string_to_sign
294
+ end
295
+
296
+ def hmac key, value
297
+ OpenSSL::HMAC.digest OpenSSL::Digest.new("sha256"), key, value
298
+ end
299
+
300
+ def hexhmac key, value
301
+ OpenSSL::HMAC.hexdigest OpenSSL::Digest.new("sha256"), key, value
302
+ end
303
+
304
+ def credential access_key_id, date, service
305
+ "#{access_key_id}/#{credential_scope date, service}"
306
+ end
307
+
308
+ def credential_scope date, service
309
+ [
310
+ date,
311
+ @region_name,
312
+ service,
313
+ "aws4_request"
314
+ ].join("/")
315
+ end
316
+
317
+ def string_to_sign datetime, canonical_request, service
318
+ [
319
+ "AWS4-HMAC-SHA256",
320
+ datetime,
321
+ credential_scope(datetime[0, 8], service),
322
+ sha256_hexdigest(canonical_request)
323
+ ].join("\n")
324
+ end
325
+
326
+ def host uri
327
+ # Handles known and unknown URI schemes; default_port nil when unknown.
328
+ if uri.default_port == uri.port
329
+ uri.host
330
+ else
331
+ "#{uri.host}:#{uri.port}"
332
+ end
333
+ end
334
+
335
+ def canonical_request http_method, uri, headers, content_sha256
336
+ headers = headers.sort_by(&:first) # transforms to a sorted array of [key, value]
337
+
338
+ [
339
+ http_method,
340
+ uri.path.empty? ? "/" : uri.path,
341
+ build_canonical_querystring(uri.query || ""),
342
+ headers.map { |k, v| "#{k}:#{v}\n" }.join, # Canonical headers
343
+ headers.map(&:first).join(";"), # Signed headers
344
+ content_sha256
345
+ ].join("\n")
346
+ end
347
+
348
+ def sha256_hexdigest string
349
+ OpenSSL::Digest::SHA256.hexdigest string
350
+ end
351
+
352
+ # Generates the canonical query string given a raw query string.
353
+ # Logic is based on
354
+ # https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
355
+ # Code is from the AWS SDK for Ruby
356
+ # https://github.com/aws/aws-sdk-ruby/blob/0ac3d0a393ed216290bfb5f0383380376f6fb1f1/gems/aws-sigv4/lib/aws-sigv4/signer.rb#L532
357
+ def build_canonical_querystring query
358
+ params = query.split "&"
359
+ params = params.map { |p| p.include?("=") ? p : "#{p}=" }
360
+
361
+ params.each.with_index.sort do |(a, a_offset), (b, b_offset)|
362
+ a_name, a_value = a.split "="
363
+ b_name, b_value = b.split "="
364
+ if a_name == b_name
365
+ if a_value == b_value
366
+ a_offset <=> b_offset
367
+ else
368
+ a_value <=> b_value
369
+ end
370
+ else
371
+ a_name <=> b_name
372
+ end
373
+ end.map(&:first).join("&")
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
@@ -0,0 +1,158 @@
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
+ module ExternalAccount
24
+ # Authenticates requests using External Account credentials, such
25
+ # as those provided by the AWS provider or OIDC provider like Azure, etc.
26
+ module BaseCredentials
27
+ # Contains all methods needed for all external account credentials.
28
+ # Other credentials should call `base_setup` during initialization
29
+ # And should define the :retrieve_subject_token! method
30
+
31
+ # External account JSON type identifier.
32
+ EXTERNAL_ACCOUNT_JSON_TYPE = "external_account".freeze
33
+ # The token exchange grant_type used for exchanging credentials.
34
+ STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
35
+ # The token exchange requested_token_type. This is always an access_token.
36
+ STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token".freeze
37
+ # Default IAM_SCOPE
38
+ IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze
39
+
40
+ include Google::Auth::BaseClient
41
+ include Helpers::Connection
42
+
43
+ attr_reader :expires_at
44
+ attr_accessor :access_token
45
+
46
+ def expires_within? seconds
47
+ # This method is needed for BaseClient
48
+ @expires_at && @expires_at - Time.now.utc < seconds
49
+ end
50
+
51
+ def expires_at= new_expires_at
52
+ @expires_at = normalize_timestamp new_expires_at
53
+ end
54
+
55
+ def fetch_access_token! _options = {}
56
+ # This method is needed for BaseClient
57
+ response = exchange_token
58
+
59
+ if @service_account_impersonation_url
60
+ impersonated_response = get_impersonated_access_token response["access_token"]
61
+ self.expires_at = impersonated_response["expireTime"]
62
+ self.access_token = impersonated_response["accessToken"]
63
+ else
64
+ # Extract the expiration time in seconds from the response and calculate the actual expiration time
65
+ # and then save that to the expiry variable.
66
+ self.expires_at = Time.now.utc + response["expires_in"].to_i
67
+ self.access_token = response["access_token"]
68
+ end
69
+
70
+ notify_refresh_listeners
71
+ end
72
+
73
+ # Retrieves the subject token using the credential_source object.
74
+ # @return [string]
75
+ # The retrieved subject token.
76
+ #
77
+ def retrieve_subject_token!
78
+ raise NotImplementedError
79
+ end
80
+
81
+ # Returns whether the credentials represent a workforce pool (True) or
82
+ # workload (False) based on the credentials' audience.
83
+ #
84
+ # @return [bool]
85
+ # true if the credentials represent a workforce pool.
86
+ # false if they represent a workload.
87
+ def is_workforce_pool?
88
+ pattern = "//iam\.googleapis\.com/locations/[^/]+/workforcePools/"
89
+ /#{pattern}/.match?(@audience || "")
90
+ end
91
+
92
+ private
93
+
94
+ def token_type
95
+ # This method is needed for BaseClient
96
+ :access_token
97
+ end
98
+
99
+ def base_setup options
100
+ self.default_connection = options[:connection]
101
+
102
+ @audience = options[:audience]
103
+ @scope = options[:scope] || IAM_SCOPE
104
+ @subject_token_type = options[:subject_token_type]
105
+ @token_url = options[:token_url]
106
+ @token_info_url = options[:token_info_url]
107
+ @service_account_impersonation_url = options[:service_account_impersonation_url]
108
+ @service_account_impersonation_options = options[:service_account_impersonation_options] || {}
109
+ @client_id = options[:client_id]
110
+ @client_secret = options[:client_secret]
111
+ @quota_project_id = options[:quota_project_id]
112
+ @project_id = nil
113
+ @workforce_pool_user_project = options[:workforce_pool_user_project]
114
+
115
+ @expires_at = nil
116
+ @access_token = nil
117
+
118
+ @sts_client = Google::Auth::OAuth2::STSClient.new(
119
+ token_exchange_endpoint: @token_url,
120
+ connection: default_connection
121
+ )
122
+ return unless @workforce_pool_user_project && !is_workforce_pool?
123
+ raise "workforce_pool_user_project should not be set for non-workforce pool credentials."
124
+ end
125
+
126
+ def exchange_token
127
+ additional_options = nil
128
+ if @client_id.nil? && @workforce_pool_user_project
129
+ additional_options = { userProject: @workforce_pool_user_project }
130
+ end
131
+ @sts_client.exchange_token(
132
+ audience: @audience,
133
+ grant_type: STS_GRANT_TYPE,
134
+ subject_token: retrieve_subject_token!,
135
+ subject_token_type: @subject_token_type,
136
+ scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope,
137
+ requested_token_type: STS_REQUESTED_TOKEN_TYPE,
138
+ additional_options: additional_options
139
+ )
140
+ end
141
+
142
+ def get_impersonated_access_token token, _options = {}
143
+ response = connection.post @service_account_impersonation_url do |req|
144
+ req.headers["Authorization"] = "Bearer #{token}"
145
+ req.headers["Content-Type"] = "application/json"
146
+ req.body = MultiJson.dump({ scope: @scope })
147
+ end
148
+
149
+ if response.status != 200
150
+ raise "Service account impersonation failed with status #{response.status}"
151
+ end
152
+
153
+ MultiJson.load response.body
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,103 @@
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
+ module ExternalAccount
24
+ # Authenticates requests using External Account credentials, such
25
+ # as those provided by the AWS provider or OIDC provider like Azure, etc.
26
+ module ExternalAccountUtils
27
+ # Cloud resource manager URL used to retrieve project information.
28
+ CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/".freeze
29
+
30
+ ##
31
+ # Retrieves the project ID corresponding to the workload identity or workforce pool.
32
+ # For workforce pool credentials, it returns the project ID corresponding to the workforce_pool_user_project.
33
+ # When not determinable, None is returned.
34
+ #
35
+ # The resource may not have permission (resourcemanager.projects.get) to
36
+ # call this API or the required scopes may not be selected:
37
+ # https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
38
+ #
39
+ # @return [string,nil]
40
+ # The project ID corresponding to the workload identity pool or workforce pool if determinable.
41
+ #
42
+ def project_id
43
+ return @project_id unless @project_id.nil?
44
+ project_number = self.project_number || @workforce_pool_user_project
45
+
46
+ # if we missing either project number or scope, we won't retrieve project_id
47
+ return nil if project_number.nil? || @scope.nil?
48
+
49
+ url = "#{CLOUD_RESOURCE_MANAGER}#{project_number}"
50
+ response = connection.get url do |req|
51
+ req.headers["Authorization"] = "Bearer #{@access_token}"
52
+ req.headers["Content-Type"] = "application/json"
53
+ end
54
+
55
+ if response.status == 200
56
+ response_data = MultiJson.load response.body, symbolize_names: true
57
+ @project_id = response_data[:projectId]
58
+ end
59
+
60
+ @project_id
61
+ end
62
+
63
+ ##
64
+ # Retrieve the project number corresponding to workload identity pool
65
+ # STS audience pattern:
66
+ # `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...`
67
+ #
68
+ # @return [string, nil]
69
+ #
70
+ def project_number
71
+ segments = @audience.split "/"
72
+ idx = segments.index "projects"
73
+ return nil if idx.nil? || idx + 1 == segments.size
74
+ segments[idx + 1]
75
+ end
76
+
77
+ def normalize_timestamp time
78
+ case time
79
+ when NilClass
80
+ nil
81
+ when Time
82
+ time
83
+ when String
84
+ Time.parse time
85
+ else
86
+ raise "Invalid time value #{time}"
87
+ end
88
+ end
89
+
90
+ def service_account_email
91
+ return nil if @service_account_impersonation_url.nil?
92
+ start_idx = @service_account_impersonation_url.rindex "/"
93
+ end_idx = @service_account_impersonation_url.index ":generateAccessToken"
94
+ if start_idx != -1 && end_idx != -1 && start_idx < end_idx
95
+ start_idx += 1
96
+ return @service_account_impersonation_url[start_idx..end_idx]
97
+ end
98
+ nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end