googleauth 1.3.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +4 -2
- data/lib/googleauth/application_default.rb +5 -5
- data/lib/googleauth/base_client.rb +80 -0
- data/lib/googleauth/client_id.rb +25 -8
- data/lib/googleauth/compute_engine.rb +23 -15
- data/lib/googleauth/credentials.rb +1 -2
- data/lib/googleauth/credentials_loader.rb +5 -13
- data/lib/googleauth/default_credentials.rb +5 -2
- data/lib/googleauth/external_account/aws_credentials.rb +378 -0
- data/lib/googleauth/external_account/base_credentials.rb +158 -0
- data/lib/googleauth/external_account/external_account_utils.rb +103 -0
- data/lib/googleauth/external_account/identity_pool_credentials.rb +118 -0
- data/lib/googleauth/external_account/pluggable_credentials.rb +156 -0
- data/lib/googleauth/external_account.rb +93 -0
- data/lib/googleauth/helpers/connection.rb +35 -0
- data/lib/googleauth/id_tokens.rb +2 -2
- data/lib/googleauth/oauth2/sts_client.rb +109 -0
- data/lib/googleauth/scope_util.rb +35 -2
- data/lib/googleauth/service_account.rb +1 -3
- data/lib/googleauth/signet.rb +3 -39
- data/lib/googleauth/user_authorizer.rb +12 -5
- data/lib/googleauth/version.rb +1 -1
- data/lib/googleauth/web_user_authorizer.rb +4 -4
- metadata +12 -17
@@ -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
|