googleauth 1.3.0 → 1.11.0
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 +93 -0
- data/README.md +43 -2
- data/lib/googleauth/application_default.rb +5 -9
- data/lib/googleauth/base_client.rb +80 -0
- data/lib/googleauth/client_id.rb +25 -8
- data/lib/googleauth/compute_engine.rb +65 -35
- data/lib/googleauth/credentials.rb +32 -30
- 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 +159 -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 +94 -0
- data/lib/googleauth/helpers/connection.rb +35 -0
- data/lib/googleauth/id_tokens.rb +2 -2
- data/lib/googleauth/json_key_reader.rb +2 -1
- data/lib/googleauth/oauth2/sts_client.rb +109 -0
- data/lib/googleauth/scope_util.rb +35 -2
- data/lib/googleauth/service_account.rb +25 -11
- data/lib/googleauth/signet.rb +14 -38
- data/lib/googleauth/user_authorizer.rb +66 -9
- data/lib/googleauth/user_refresh.rb +4 -2
- data/lib/googleauth/version.rb +1 -1
- data/lib/googleauth/web_user_authorizer.rb +19 -8
- metadata +29 -20
@@ -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
|
@@ -44,13 +49,6 @@ module Google
|
|
44
49
|
CLOUD_SDK_CLIENT_ID = "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.app" \
|
45
50
|
"s.googleusercontent.com".freeze
|
46
51
|
|
47
|
-
CLOUD_SDK_CREDENTIALS_WARNING =
|
48
|
-
"Your application has authenticated using end user credentials from Google Cloud SDK. We recommend that most " \
|
49
|
-
"server applications use service accounts instead. If your application continues to use end user credentials " \
|
50
|
-
'from Cloud SDK, you might receive a "quota exceeded" or "API not enabled" error. For more information about ' \
|
51
|
-
"service accounts, see https://cloud.google.com/docs/authentication/. To suppress this message, set the " \
|
52
|
-
"GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS environment variable.".freeze
|
53
|
-
|
54
52
|
# make_creds proxies the construction of a credentials instance
|
55
53
|
#
|
56
54
|
# By default, it calls #new on the current class, but this behaviour can
|
@@ -144,12 +142,6 @@ module Google
|
|
144
142
|
|
145
143
|
module_function
|
146
144
|
|
147
|
-
# Issues warning if cloud sdk client id is used
|
148
|
-
def warn_if_cloud_sdk_credentials client_id
|
149
|
-
return if ENV["GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS"]
|
150
|
-
warn CLOUD_SDK_CREDENTIALS_WARNING if client_id == CLOUD_SDK_CLIENT_ID
|
151
|
-
end
|
152
|
-
|
153
145
|
# Finds project_id from gcloud CLI configuration
|
154
146
|
def load_gcloud_project_id
|
155
147
|
gcloud = GCLOUD_WINDOWS_COMMAND if OS.windows?
|
@@ -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
|
@@ -34,11 +35,9 @@ module Google
|
|
34
35
|
json_key_io = options[:json_key_io]
|
35
36
|
if json_key_io
|
36
37
|
json_key, clz = determine_creds_class json_key_io
|
37
|
-
warn_if_cloud_sdk_credentials json_key["client_id"]
|
38
38
|
io = StringIO.new MultiJson.dump(json_key)
|
39
39
|
clz.make_creds options.merge(json_key_io: io)
|
40
40
|
else
|
41
|
-
warn_if_cloud_sdk_credentials ENV[CredentialsLoader::CLIENT_ID_VAR]
|
42
41
|
clz = read_creds
|
43
42
|
clz.make_creds options
|
44
43
|
end
|
@@ -53,6 +52,8 @@ module Google
|
|
53
52
|
ServiceAccountCredentials
|
54
53
|
when "authorized_user"
|
55
54
|
UserRefreshCredentials
|
55
|
+
when "external_account"
|
56
|
+
ExternalAccount::Credentials
|
56
57
|
else
|
57
58
|
raise "credentials type '#{type}' is not supported"
|
58
59
|
end
|
@@ -69,6 +70,8 @@ module Google
|
|
69
70
|
[json_key, ServiceAccountCredentials]
|
70
71
|
when "authorized_user"
|
71
72
|
[json_key, UserRefreshCredentials]
|
73
|
+
when "external_account"
|
74
|
+
[json_key, ExternalAccount::Credentials]
|
72
75
|
else
|
73
76
|
raise "credentials type '#{type}' is not supported"
|
74
77
|
end
|
@@ -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,159 @@
|
|
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
|
+
attr_accessor :universe_domain
|
46
|
+
|
47
|
+
def expires_within? seconds
|
48
|
+
# This method is needed for BaseClient
|
49
|
+
@expires_at && @expires_at - Time.now.utc < seconds
|
50
|
+
end
|
51
|
+
|
52
|
+
def expires_at= new_expires_at
|
53
|
+
@expires_at = normalize_timestamp new_expires_at
|
54
|
+
end
|
55
|
+
|
56
|
+
def fetch_access_token! _options = {}
|
57
|
+
# This method is needed for BaseClient
|
58
|
+
response = exchange_token
|
59
|
+
|
60
|
+
if @service_account_impersonation_url
|
61
|
+
impersonated_response = get_impersonated_access_token response["access_token"]
|
62
|
+
self.expires_at = impersonated_response["expireTime"]
|
63
|
+
self.access_token = impersonated_response["accessToken"]
|
64
|
+
else
|
65
|
+
# Extract the expiration time in seconds from the response and calculate the actual expiration time
|
66
|
+
# and then save that to the expiry variable.
|
67
|
+
self.expires_at = Time.now.utc + response["expires_in"].to_i
|
68
|
+
self.access_token = response["access_token"]
|
69
|
+
end
|
70
|
+
|
71
|
+
notify_refresh_listeners
|
72
|
+
end
|
73
|
+
|
74
|
+
# Retrieves the subject token using the credential_source object.
|
75
|
+
# @return [string]
|
76
|
+
# The retrieved subject token.
|
77
|
+
#
|
78
|
+
def retrieve_subject_token!
|
79
|
+
raise NotImplementedError
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns whether the credentials represent a workforce pool (True) or
|
83
|
+
# workload (False) based on the credentials' audience.
|
84
|
+
#
|
85
|
+
# @return [bool]
|
86
|
+
# true if the credentials represent a workforce pool.
|
87
|
+
# false if they represent a workload.
|
88
|
+
def is_workforce_pool?
|
89
|
+
%r{/iam\.googleapis\.com/locations/[^/]+/workforcePools/}.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
|
+
@universe_domain = options[:universe_domain] || "googleapis.com"
|
115
|
+
|
116
|
+
@expires_at = nil
|
117
|
+
@access_token = nil
|
118
|
+
|
119
|
+
@sts_client = Google::Auth::OAuth2::STSClient.new(
|
120
|
+
token_exchange_endpoint: @token_url,
|
121
|
+
connection: default_connection
|
122
|
+
)
|
123
|
+
return unless @workforce_pool_user_project && !is_workforce_pool?
|
124
|
+
raise "workforce_pool_user_project should not be set for non-workforce pool credentials."
|
125
|
+
end
|
126
|
+
|
127
|
+
def exchange_token
|
128
|
+
additional_options = nil
|
129
|
+
if @client_id.nil? && @workforce_pool_user_project
|
130
|
+
additional_options = { userProject: @workforce_pool_user_project }
|
131
|
+
end
|
132
|
+
@sts_client.exchange_token(
|
133
|
+
audience: @audience,
|
134
|
+
grant_type: STS_GRANT_TYPE,
|
135
|
+
subject_token: retrieve_subject_token!,
|
136
|
+
subject_token_type: @subject_token_type,
|
137
|
+
scopes: @service_account_impersonation_url ? IAM_SCOPE : @scope,
|
138
|
+
requested_token_type: STS_REQUESTED_TOKEN_TYPE,
|
139
|
+
additional_options: additional_options
|
140
|
+
)
|
141
|
+
end
|
142
|
+
|
143
|
+
def get_impersonated_access_token token, _options = {}
|
144
|
+
response = connection.post @service_account_impersonation_url do |req|
|
145
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
146
|
+
req.headers["Content-Type"] = "application/json"
|
147
|
+
req.body = MultiJson.dump({ scope: @scope })
|
148
|
+
end
|
149
|
+
|
150
|
+
if response.status != 200
|
151
|
+
raise "Service account impersonation failed with status #{response.status}"
|
152
|
+
end
|
153
|
+
|
154
|
+
MultiJson.load response.body
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|