googleauth 1.3.0 → 1.8.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|