googleauth 1.11.1 → 1.13.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 +40 -0
- data/README.md +9 -0
- data/lib/googleauth/base_client.rb +13 -1
- data/lib/googleauth/compute_engine.rb +130 -25
- data/lib/googleauth/credentials.rb +139 -29
- data/lib/googleauth/default_credentials.rb +12 -1
- data/lib/googleauth/external_account/base_credentials.rb +33 -2
- data/lib/googleauth/id_tokens.rb +0 -2
- data/lib/googleauth/impersonated_service_account.rb +282 -0
- data/lib/googleauth/service_account.rb +114 -7
- data/lib/googleauth/signet.rb +120 -2
- data/lib/googleauth/user_refresh.rb +43 -0
- data/lib/googleauth/version.rb +1 -1
- metadata +20 -8
@@ -0,0 +1,282 @@
|
|
1
|
+
# Copyright 2024 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 "googleauth/signet"
|
16
|
+
require "googleauth/base_client"
|
17
|
+
require "googleauth/helpers/connection"
|
18
|
+
|
19
|
+
module Google
|
20
|
+
module Auth
|
21
|
+
# Authenticates requests using impersonation from base credentials.
|
22
|
+
# This is a two-step process: first authentication claim from the base credentials is created
|
23
|
+
# and then that claim is exchanged for a short-lived token at an IAMCredentials endpoint.
|
24
|
+
# The short-lived token and its expiration time are cached.
|
25
|
+
class ImpersonatedServiceAccountCredentials
|
26
|
+
# @private
|
27
|
+
ERROR_SUFFIX = <<~ERROR.freeze
|
28
|
+
when trying to get security access token
|
29
|
+
from IAM Credentials endpoint using the credentials provided.
|
30
|
+
ERROR
|
31
|
+
|
32
|
+
# @private
|
33
|
+
IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze
|
34
|
+
|
35
|
+
# BaseClient most importantly implements the `:updater_proc` getter,
|
36
|
+
# that returns a reference to an `apply!` method that updates
|
37
|
+
# a hash argument provided with the authorization header containing
|
38
|
+
# the access token (impersonation token in this case).
|
39
|
+
include Google::Auth::BaseClient
|
40
|
+
|
41
|
+
include Helpers::Connection
|
42
|
+
|
43
|
+
# @return [Object] The original authenticated credentials used to fetch short-lived impersonation access tokens
|
44
|
+
attr_reader :base_credentials
|
45
|
+
|
46
|
+
# @return [Object] The modified version of base credentials, tailored for impersonation purposes
|
47
|
+
# with necessary scope adjustments
|
48
|
+
attr_reader :source_credentials
|
49
|
+
|
50
|
+
# @return [String] The URL endpoint used to generate an impersonation token. This URL should follow a specific
|
51
|
+
# format to specify the impersonated service account.
|
52
|
+
attr_reader :impersonation_url
|
53
|
+
|
54
|
+
# @return [Array<String>, String] The scope(s) required for the impersonated access token,
|
55
|
+
# indicating the permissions needed for the short-lived token
|
56
|
+
attr_reader :scope
|
57
|
+
|
58
|
+
# @return [String, nil] The short-lived impersonation access token, retrieved and cached
|
59
|
+
# after making the impersonation request
|
60
|
+
attr_reader :access_token
|
61
|
+
|
62
|
+
# @return [Time, nil] The expiration time of the current access token, used to determine
|
63
|
+
# if the token is still valid
|
64
|
+
attr_reader :expires_at
|
65
|
+
|
66
|
+
# Create a ImpersonatedServiceAccountCredentials
|
67
|
+
# When you use service account impersonation, you start with an authenticated principal
|
68
|
+
# (e.g. your user account or a service account)
|
69
|
+
# and request short-lived credentials for a service account
|
70
|
+
# that has the authorization that your use case requires.
|
71
|
+
#
|
72
|
+
# @param options [Hash] A hash of options to configure the credentials.
|
73
|
+
# @option options [Object] :base_credentials (required) The authenticated principal.
|
74
|
+
# It will be used as following:
|
75
|
+
# * will be duplicated (with IAM scope) to create the source credentials if it supports duplication
|
76
|
+
# * as source credentials otherwise.
|
77
|
+
# @option options [String] :impersonation_url (required) The URL to impersonate the service account.
|
78
|
+
# This URL should follow the format:
|
79
|
+
# `https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{source_sa_email}:generateAccessToken`,
|
80
|
+
# where:
|
81
|
+
# - `{universe_domain}` is the domain of the IAMCredentials API endpoint (e.g., `googleapis.com`).
|
82
|
+
# - `{source_sa_email}` is the email address of the service account to impersonate.
|
83
|
+
# @option options [Array<String>, String] :scope (required) The scope(s) for the short-lived impersonation token,
|
84
|
+
# defining the permissions required for the token.
|
85
|
+
# @option options [Object] :source_credentials The authenticated principal that will be used
|
86
|
+
# to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials.
|
87
|
+
#
|
88
|
+
# @return [Google::Auth::ImpersonatedServiceAccountCredentials]
|
89
|
+
def self.make_creds options = {}
|
90
|
+
new options
|
91
|
+
end
|
92
|
+
|
93
|
+
# Initializes a new instance of ImpersonatedServiceAccountCredentials.
|
94
|
+
#
|
95
|
+
# @param options [Hash] A hash of options to configure the credentials.
|
96
|
+
# @option options [Object] :base_credentials (required) The authenticated principal.
|
97
|
+
# It will be used as following:
|
98
|
+
# * will be duplicated (with IAM scope) to create the source credentials if it supports duplication
|
99
|
+
# * as source credentials otherwise.
|
100
|
+
# @option options [String] :impersonation_url (required) The URL to impersonate the service account.
|
101
|
+
# This URL should follow the format:
|
102
|
+
# `https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{source_sa_email}:generateAccessToken`,
|
103
|
+
# where:
|
104
|
+
# - `{universe_domain}` is the domain of the IAMCredentials API endpoint (e.g., `googleapis.com`).
|
105
|
+
# - `{source_sa_email}` is the email address of the service account to impersonate.
|
106
|
+
# @option options [Array<String>, String] :scope (required) The scope(s) for the short-lived impersonation token,
|
107
|
+
# defining the permissions required for the token.
|
108
|
+
# @option options [Object] :source_credentials The authenticated principal that will be used
|
109
|
+
# to fetch the short-lived impersonation access token. It is an alternative to providing the base credentials.
|
110
|
+
# It is redundant to provide both source and base credentials as only source will be used,
|
111
|
+
# but it can be done, e.g. when duplicating existing credentials.
|
112
|
+
#
|
113
|
+
# @raise [ArgumentError] If any of the required options are missing.
|
114
|
+
#
|
115
|
+
# @return [Google::Auth::ImpersonatedServiceAccountCredentials]
|
116
|
+
def initialize options = {}
|
117
|
+
@base_credentials, @impersonation_url, @scope =
|
118
|
+
options.values_at :base_credentials,
|
119
|
+
:impersonation_url,
|
120
|
+
:scope
|
121
|
+
|
122
|
+
# Fail-fast checks for required parameters
|
123
|
+
if @base_credentials.nil? && !options.key?(:source_credentials)
|
124
|
+
raise ArgumentError, "Missing required option: either :base_credentials or :source_credentials"
|
125
|
+
end
|
126
|
+
raise ArgumentError, "Missing required option: :impersonation_url" if @impersonation_url.nil?
|
127
|
+
raise ArgumentError, "Missing required option: :scope" if @scope.nil?
|
128
|
+
|
129
|
+
# Some credentials (all Signet-based ones and this one) include scope and a bunch of transient state
|
130
|
+
# (e.g. refresh status) as part of themselves
|
131
|
+
# so a copy needs to be created with the scope overriden and transient state dropped.
|
132
|
+
#
|
133
|
+
# If a credentials does not support `duplicate` we'll try to use it as is assuming it has a broad enough scope.
|
134
|
+
# This might result in an "access denied" error downstream when the token from that credentials is being used
|
135
|
+
# for the token exchange.
|
136
|
+
@source_credentials = if options.key? :source_credentials
|
137
|
+
options[:source_credentials]
|
138
|
+
elsif @base_credentials.respond_to? :duplicate
|
139
|
+
@base_credentials.duplicate({
|
140
|
+
scope: IAM_SCOPE
|
141
|
+
})
|
142
|
+
else
|
143
|
+
@base_credentials
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Determines whether the current access token expires within the specified number of seconds.
|
148
|
+
#
|
149
|
+
# @param seconds [Integer] The number of seconds to check against the token's expiration time.
|
150
|
+
#
|
151
|
+
# @return [Boolean] Whether the access token expires within the given time frame
|
152
|
+
def expires_within? seconds
|
153
|
+
# This method is needed for BaseClient
|
154
|
+
@expires_at && @expires_at - Time.now.utc < seconds
|
155
|
+
end
|
156
|
+
|
157
|
+
# The universe domain of the impersonated credentials.
|
158
|
+
# Effectively this retrieves the universe domain of the source credentials.
|
159
|
+
#
|
160
|
+
# @return [String] The universe domain of the credentials.
|
161
|
+
def universe_domain
|
162
|
+
@source_credentials.universe_domain
|
163
|
+
end
|
164
|
+
|
165
|
+
# @return [Logger, nil] The logger of the credentials.
|
166
|
+
def logger
|
167
|
+
@source_credentials.logger if source_credentials.respond_to? :logger
|
168
|
+
end
|
169
|
+
|
170
|
+
# Creates a duplicate of these credentials without transient token state
|
171
|
+
#
|
172
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
173
|
+
# The following keys are recognized
|
174
|
+
# * `base_credentials` the base credentials used to initialize the impersonation
|
175
|
+
# * `source_credentials` the authenticated credentials which usually would be
|
176
|
+
# base credentials with scope overridden to IAM_SCOPE
|
177
|
+
# * `impersonation_url` the URL to use to make an impersonation token exchange
|
178
|
+
# * `scope` the scope(s) to access
|
179
|
+
#
|
180
|
+
# @return [Google::Auth::ImpersonatedServiceAccountCredentials]
|
181
|
+
def duplicate options = {}
|
182
|
+
options = deep_hash_normalize options
|
183
|
+
|
184
|
+
options = {
|
185
|
+
base_credentials: @base_credentials,
|
186
|
+
source_credentials: @source_credentials,
|
187
|
+
impersonation_url: @impersonation_url,
|
188
|
+
scope: @scope
|
189
|
+
}.merge(options)
|
190
|
+
|
191
|
+
self.class.new options
|
192
|
+
end
|
193
|
+
|
194
|
+
private
|
195
|
+
|
196
|
+
# Generates a new impersonation access token by exchanging the source credentials' token
|
197
|
+
# at the impersonation URL.
|
198
|
+
#
|
199
|
+
# This method first fetches an access token from the source credentials and then exchanges it
|
200
|
+
# for an impersonation token using the specified impersonation URL. The generated token and
|
201
|
+
# its expiration time are cached for subsequent use.
|
202
|
+
#
|
203
|
+
# @param _options [Hash] (optional) Additional options for token retrieval (currently unused).
|
204
|
+
#
|
205
|
+
# @raise [Signet::UnexpectedStatusError] If the response status is 403 or 500.
|
206
|
+
# @raise [Signet::AuthorizationError] For other unexpected response statuses.
|
207
|
+
#
|
208
|
+
# @return [String] The newly generated impersonation access token.
|
209
|
+
def fetch_access_token! _options = {}
|
210
|
+
auth_header = {}
|
211
|
+
auth_header = @source_credentials.updater_proc.call auth_header
|
212
|
+
|
213
|
+
resp = connection.post @impersonation_url do |req|
|
214
|
+
req.headers.merge! auth_header
|
215
|
+
req.headers["Content-Type"] = "application/json"
|
216
|
+
req.body = MultiJson.dump({ scope: @scope })
|
217
|
+
end
|
218
|
+
|
219
|
+
case resp.status
|
220
|
+
when 200
|
221
|
+
response = MultiJson.load resp.body
|
222
|
+
self.expires_at = response["expireTime"]
|
223
|
+
@access_token = response["accessToken"]
|
224
|
+
access_token
|
225
|
+
when 403, 500
|
226
|
+
msg = "Unexpected error code #{resp.status}.\n #{resp.env.response_body} #{ERROR_SUFFIX}"
|
227
|
+
raise Signet::UnexpectedStatusError, msg
|
228
|
+
else
|
229
|
+
msg = "Unexpected error code #{resp.status}.\n #{resp.env.response_body} #{ERROR_SUFFIX}"
|
230
|
+
raise Signet::AuthorizationError, msg
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
# Setter for the expires_at value that makes sure it is converted
|
235
|
+
# to Time object.
|
236
|
+
def expires_at= new_expires_at
|
237
|
+
@expires_at = normalize_timestamp new_expires_at
|
238
|
+
end
|
239
|
+
|
240
|
+
# Returns the type of token (access_token).
|
241
|
+
# This method is needed for BaseClient.
|
242
|
+
def token_type
|
243
|
+
:access_token
|
244
|
+
end
|
245
|
+
|
246
|
+
# Normalizes a timestamp to a Time object.
|
247
|
+
#
|
248
|
+
# @param time [Time, String, nil] The timestamp to normalize.
|
249
|
+
#
|
250
|
+
# @return [Time, nil] The normalized Time object, or nil if the input is nil.
|
251
|
+
#
|
252
|
+
# @raise [RuntimeError] If the input is not a Time, String, or nil.
|
253
|
+
def normalize_timestamp time
|
254
|
+
case time
|
255
|
+
when NilClass
|
256
|
+
nil
|
257
|
+
when Time
|
258
|
+
time
|
259
|
+
when String
|
260
|
+
Time.parse time
|
261
|
+
else
|
262
|
+
raise "Invalid time value #{time}"
|
263
|
+
end
|
264
|
+
end
|
265
|
+
|
266
|
+
# Convert all keys in this hash (nested) to symbols for uniform retrieval
|
267
|
+
def recursive_hash_normalize_keys val
|
268
|
+
if val.is_a? Hash
|
269
|
+
deep_hash_normalize val
|
270
|
+
else
|
271
|
+
val
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def deep_hash_normalize old_hash
|
276
|
+
sym_hash = {}
|
277
|
+
old_hash&.each { |k, v| sym_hash[k.to_sym] = recursive_hash_normalize_keys v }
|
278
|
+
sym_hash
|
279
|
+
end
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
@@ -12,6 +12,7 @@
|
|
12
12
|
# See the License for the specific language governing permissions and
|
13
13
|
# limitations under the License.
|
14
14
|
|
15
|
+
require "google/logging/message"
|
15
16
|
require "googleauth/signet"
|
16
17
|
require "googleauth/credentials_loader"
|
17
18
|
require "googleauth/json_key_reader"
|
@@ -80,6 +81,30 @@ module Google
|
|
80
81
|
.configure_connection(options)
|
81
82
|
end
|
82
83
|
|
84
|
+
# Creates a duplicate of these credentials
|
85
|
+
# without the Signet::OAuth2::Client-specific
|
86
|
+
# transient state (e.g. cached tokens)
|
87
|
+
#
|
88
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
89
|
+
# The following keys are recognized in addition to keys in the
|
90
|
+
# Signet::OAuth2::Client
|
91
|
+
# * `:enable_self_signed_jwt` Whether the self-signed JWT should
|
92
|
+
# be used for the authentication
|
93
|
+
# * `project_id` the project id to use during the authentication
|
94
|
+
# * `quota_project_id` the quota project id to use
|
95
|
+
# during the authentication
|
96
|
+
def duplicate options = {}
|
97
|
+
options = deep_hash_normalize options
|
98
|
+
super(
|
99
|
+
{
|
100
|
+
enable_self_signed_jwt: @enable_self_signed_jwt,
|
101
|
+
project_id: project_id,
|
102
|
+
quota_project_id: quota_project_id,
|
103
|
+
logger: logger
|
104
|
+
}.merge(options)
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
83
108
|
# Handles certain escape sequences that sometimes appear in input.
|
84
109
|
# Specifically, interprets the "\n" sequence for newline, and removes
|
85
110
|
# enclosing quotes.
|
@@ -111,6 +136,32 @@ module Google
|
|
111
136
|
super && !enable_self_signed_jwt?
|
112
137
|
end
|
113
138
|
|
139
|
+
# Destructively updates these credentials
|
140
|
+
#
|
141
|
+
# This method is called by `Signet::OAuth2::Client`'s constructor
|
142
|
+
#
|
143
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
144
|
+
# The following keys are recognized in addition to keys in the
|
145
|
+
# Signet::OAuth2::Client
|
146
|
+
# * `:enable_self_signed_jwt` Whether the self-signed JWT should
|
147
|
+
# be used for the authentication
|
148
|
+
# * `project_id` the project id to use during the authentication
|
149
|
+
# * `quota_project_id` the quota project id to use
|
150
|
+
# during the authentication
|
151
|
+
# @return [Google::Auth::ServiceAccountCredentials]
|
152
|
+
def update! options = {}
|
153
|
+
# Normalize all keys to symbols to allow indifferent access.
|
154
|
+
options = deep_hash_normalize options
|
155
|
+
|
156
|
+
@enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false
|
157
|
+
@project_id = options[:project_id] if options.key? :project_id
|
158
|
+
@quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
|
159
|
+
|
160
|
+
super(options)
|
161
|
+
|
162
|
+
self
|
163
|
+
end
|
164
|
+
|
114
165
|
private
|
115
166
|
|
116
167
|
def apply_self_signed_jwt! a_hash
|
@@ -123,6 +174,7 @@ module Google
|
|
123
174
|
}
|
124
175
|
key_io = StringIO.new MultiJson.dump(cred_json)
|
125
176
|
alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io, scope: scope
|
177
|
+
alt.logger = logger
|
126
178
|
alt.apply! a_hash
|
127
179
|
end
|
128
180
|
end
|
@@ -142,11 +194,14 @@ module Google
|
|
142
194
|
TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
|
143
195
|
SIGNING_ALGORITHM = "RS256".freeze
|
144
196
|
EXPIRY = 60
|
197
|
+
|
145
198
|
extend CredentialsLoader
|
146
199
|
extend JsonKeyReader
|
200
|
+
|
147
201
|
attr_reader :project_id
|
148
202
|
attr_reader :quota_project_id
|
149
203
|
attr_accessor :universe_domain
|
204
|
+
attr_accessor :logger
|
150
205
|
|
151
206
|
# Create a ServiceAccountJwtHeaderCredentials.
|
152
207
|
#
|
@@ -166,16 +221,43 @@ module Google
|
|
166
221
|
@private_key, @issuer, @project_id, @quota_project_id, @universe_domain =
|
167
222
|
self.class.read_json_key json_key_io
|
168
223
|
else
|
169
|
-
@private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
|
170
|
-
@issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
|
171
|
-
@project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
|
172
|
-
@quota_project_id =
|
173
|
-
@universe_domain =
|
224
|
+
@private_key = options.key?(:private_key) ? options[:private_key] : ENV[CredentialsLoader::PRIVATE_KEY_VAR]
|
225
|
+
@issuer = options.key?(:issuer) ? options[:issuer] : ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
|
226
|
+
@project_id = options.key?(:project_id) ? options[:project_id] : ENV[CredentialsLoader::PROJECT_ID_VAR]
|
227
|
+
@quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
|
228
|
+
@universe_domain = options[:universe_domain] if options.key? :universe_domain
|
174
229
|
end
|
175
230
|
@universe_domain ||= "googleapis.com"
|
176
231
|
@project_id ||= CredentialsLoader.load_gcloud_project_id
|
177
232
|
@signing_key = OpenSSL::PKey::RSA.new @private_key
|
178
|
-
@scope = options[:scope]
|
233
|
+
@scope = options[:scope] if options.key? :scope
|
234
|
+
@logger = options[:logger] if options.key? :scope
|
235
|
+
end
|
236
|
+
|
237
|
+
# Creates a duplicate of these credentials
|
238
|
+
#
|
239
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
240
|
+
# The following keys are recognized
|
241
|
+
# * `private key` the private key in string form
|
242
|
+
# * `issuer` the SA issuer
|
243
|
+
# * `scope` the scope(s) to access
|
244
|
+
# * `project_id` the project id to use during the authentication
|
245
|
+
# * `quota_project_id` the quota project id to use
|
246
|
+
# * `universe_domain` the universe domain of the credentials
|
247
|
+
def duplicate options = {}
|
248
|
+
options = deep_hash_normalize options
|
249
|
+
|
250
|
+
options = {
|
251
|
+
private_key: @private_key,
|
252
|
+
issuer: @issuer,
|
253
|
+
scope: @scope,
|
254
|
+
project_id: project_id,
|
255
|
+
quota_project_id: quota_project_id,
|
256
|
+
universe_domain: universe_domain,
|
257
|
+
logger: logger
|
258
|
+
}.merge(options)
|
259
|
+
|
260
|
+
self.class.new options
|
179
261
|
end
|
180
262
|
|
181
263
|
# Construct a jwt token if the JWT_AUD_URI key is present in the input
|
@@ -187,10 +269,14 @@ module Google
|
|
187
269
|
return a_hash if jwt_aud_uri.nil? && @scope.nil?
|
188
270
|
jwt_token = new_jwt_token jwt_aud_uri, opts
|
189
271
|
a_hash[AUTH_METADATA_KEY] = "Bearer #{jwt_token}"
|
272
|
+
logger&.debug do
|
273
|
+
hash = Digest::SHA256.hexdigest jwt_token
|
274
|
+
Google::Logging::Message.from message: "Sending JWT auth token. (sha256:#{hash})"
|
275
|
+
end
|
190
276
|
a_hash
|
191
277
|
end
|
192
278
|
|
193
|
-
# Returns a clone of a_hash updated with the
|
279
|
+
# Returns a clone of a_hash updated with the authorization header
|
194
280
|
def apply a_hash, opts = {}
|
195
281
|
a_copy = a_hash.clone
|
196
282
|
apply! a_copy, opts
|
@@ -219,6 +305,10 @@ module Google
|
|
219
305
|
assertion["scope"] = Array(@scope).join " " if @scope
|
220
306
|
assertion["aud"] = jwt_aud_uri if jwt_aud_uri
|
221
307
|
|
308
|
+
logger&.debug do
|
309
|
+
Google::Logging::Message.from message: "JWT assertion: #{assertion}"
|
310
|
+
end
|
311
|
+
|
222
312
|
JWT.encode assertion, @signing_key, SIGNING_ALGORITHM
|
223
313
|
end
|
224
314
|
|
@@ -226,6 +316,23 @@ module Google
|
|
226
316
|
def needs_access_token?
|
227
317
|
false
|
228
318
|
end
|
319
|
+
|
320
|
+
private
|
321
|
+
|
322
|
+
def deep_hash_normalize old_hash
|
323
|
+
sym_hash = {}
|
324
|
+
old_hash&.each { |k, v| sym_hash[k.to_sym] = recursive_hash_normalize_keys v }
|
325
|
+
sym_hash
|
326
|
+
end
|
327
|
+
|
328
|
+
# Convert all keys in this hash (nested) to symbols for uniform retrieval
|
329
|
+
def recursive_hash_normalize_keys val
|
330
|
+
if val.is_a? Hash
|
331
|
+
deep_hash_normalize val
|
332
|
+
else
|
333
|
+
val
|
334
|
+
end
|
335
|
+
end
|
229
336
|
end
|
230
337
|
end
|
231
338
|
end
|
data/lib/googleauth/signet.rb
CHANGED
@@ -38,6 +38,22 @@ module Signet
|
|
38
38
|
self
|
39
39
|
end
|
40
40
|
|
41
|
+
alias update_signet_base update!
|
42
|
+
def update! options = {}
|
43
|
+
# Normalize all keys to symbols to allow indifferent access.
|
44
|
+
options = deep_hash_normalize options
|
45
|
+
|
46
|
+
# This `update!` method "overide" adds the `@logger`` update and
|
47
|
+
# the `universe_domain` update.
|
48
|
+
#
|
49
|
+
# The `universe_domain` is also updated in `update_token!` but is
|
50
|
+
# included here for completeness
|
51
|
+
self.universe_domain = options[:universe_domain] if options.key? :universe_domain
|
52
|
+
@logger = options[:logger] if options.key? :logger
|
53
|
+
|
54
|
+
update_signet_base options
|
55
|
+
end
|
56
|
+
|
41
57
|
def configure_connection options
|
42
58
|
@connection_info =
|
43
59
|
options[:connection_builder] || options[:default_connection]
|
@@ -65,6 +81,24 @@ module Signet
|
|
65
81
|
info
|
66
82
|
end
|
67
83
|
|
84
|
+
alias googleauth_orig_generate_access_token_request generate_access_token_request
|
85
|
+
def generate_access_token_request options = {}
|
86
|
+
parameters = googleauth_orig_generate_access_token_request options
|
87
|
+
logger&.info do
|
88
|
+
Google::Logging::Message.from(
|
89
|
+
message: "Requesting access token from #{parameters['grant_type']}",
|
90
|
+
"credentialsId" => object_id
|
91
|
+
)
|
92
|
+
end
|
93
|
+
logger&.debug do
|
94
|
+
Google::Logging::Message.from(
|
95
|
+
message: "Token fetch params: #{parameters}",
|
96
|
+
"credentialsId" => object_id
|
97
|
+
)
|
98
|
+
end
|
99
|
+
parameters
|
100
|
+
end
|
101
|
+
|
68
102
|
def build_default_connection
|
69
103
|
if !defined?(@connection_info)
|
70
104
|
nil
|
@@ -79,21 +113,62 @@ module Signet
|
|
79
113
|
retry_count = 0
|
80
114
|
|
81
115
|
begin
|
82
|
-
yield
|
116
|
+
yield.tap { |resp| log_response resp }
|
83
117
|
rescue StandardError => e
|
84
|
-
|
118
|
+
if e.is_a?(Signet::AuthorizationError) || e.is_a?(Signet::ParseError)
|
119
|
+
log_auth_error e
|
120
|
+
raise e
|
121
|
+
end
|
85
122
|
|
86
123
|
if retry_count < max_retry_count
|
124
|
+
log_transient_error e
|
87
125
|
retry_count += 1
|
88
126
|
sleep retry_count * 0.3
|
89
127
|
retry
|
90
128
|
else
|
129
|
+
log_retries_exhausted e
|
91
130
|
msg = "Unexpected error: #{e.inspect}"
|
92
131
|
raise Signet::AuthorizationError, msg
|
93
132
|
end
|
94
133
|
end
|
95
134
|
end
|
96
135
|
|
136
|
+
# Creates a duplicate of these credentials
|
137
|
+
# without the Signet::OAuth2::Client-specific
|
138
|
+
# transient state (e.g. cached tokens)
|
139
|
+
#
|
140
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
141
|
+
# @see Signet::OAuth2::Client#update!
|
142
|
+
def duplicate options = {}
|
143
|
+
options = deep_hash_normalize options
|
144
|
+
|
145
|
+
opts = {
|
146
|
+
authorization_uri: @authorization_uri,
|
147
|
+
token_credential_uri: @token_credential_uri,
|
148
|
+
client_id: @client_id,
|
149
|
+
client_secret: @client_secret,
|
150
|
+
scope: @scope,
|
151
|
+
target_audience: @target_audience,
|
152
|
+
redirect_uri: @redirect_uri,
|
153
|
+
username: @username,
|
154
|
+
password: @password,
|
155
|
+
issuer: @issuer,
|
156
|
+
person: @person,
|
157
|
+
sub: @sub,
|
158
|
+
audience: @audience,
|
159
|
+
signing_key: @signing_key,
|
160
|
+
extension_parameters: @extension_parameters,
|
161
|
+
additional_parameters: @additional_parameters,
|
162
|
+
access_type: @access_type,
|
163
|
+
universe_domain: @universe_domain,
|
164
|
+
logger: @logger
|
165
|
+
}.merge(options)
|
166
|
+
|
167
|
+
new_client = self.class.new opts
|
168
|
+
|
169
|
+
new_client.configure_connection options
|
170
|
+
end
|
171
|
+
|
97
172
|
private
|
98
173
|
|
99
174
|
def expires_at_from_id_token id_token
|
@@ -106,6 +181,49 @@ module Signet
|
|
106
181
|
# Shouldn't happen unless we get a garbled ID token
|
107
182
|
nil
|
108
183
|
end
|
184
|
+
|
185
|
+
def log_response token_response
|
186
|
+
response_hash = JSON.parse token_response rescue {}
|
187
|
+
if response_hash["access_token"]
|
188
|
+
digest = Digest::SHA256.hexdigest response_hash["access_token"]
|
189
|
+
response_hash["access_token"] = "(sha256:#{digest})"
|
190
|
+
end
|
191
|
+
if response_hash["id_token"]
|
192
|
+
digest = Digest::SHA256.hexdigest response_hash["id_token"]
|
193
|
+
response_hash["id_token"] = "(sha256:#{digest})"
|
194
|
+
end
|
195
|
+
Google::Logging::Message.from(
|
196
|
+
message: "Received auth token response: #{response_hash}",
|
197
|
+
"credentialsId" => object_id
|
198
|
+
)
|
199
|
+
end
|
200
|
+
|
201
|
+
def log_auth_error err
|
202
|
+
logger&.info do
|
203
|
+
Google::Logging::Message.from(
|
204
|
+
message: "Auth error when fetching auth token: #{err}",
|
205
|
+
"credentialsId" => object_id
|
206
|
+
)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def log_transient_error err
|
211
|
+
logger&.info do
|
212
|
+
Google::Logging::Message.from(
|
213
|
+
message: "Transient error when fetching auth token: #{err}",
|
214
|
+
"credentialsId" => object_id
|
215
|
+
)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
def log_retries_exhausted err
|
220
|
+
logger&.info do
|
221
|
+
Google::Logging::Message.from(
|
222
|
+
message: "Exhausted retries when fetching auth token: #{err}",
|
223
|
+
"credentialsId" => object_id
|
224
|
+
)
|
225
|
+
end
|
226
|
+
end
|
109
227
|
end
|
110
228
|
end
|
111
229
|
end
|
@@ -85,6 +85,26 @@ module Google
|
|
85
85
|
super options
|
86
86
|
end
|
87
87
|
|
88
|
+
# Creates a duplicate of these credentials
|
89
|
+
# without the Signet::OAuth2::Client-specific
|
90
|
+
# transient state (e.g. cached tokens)
|
91
|
+
#
|
92
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
93
|
+
# The following keys are recognized in addition to keys in the
|
94
|
+
# Signet::OAuth2::Client
|
95
|
+
# * `project_id` the project id to use during the authentication
|
96
|
+
# * `quota_project_id` the quota project id to use
|
97
|
+
# during the authentication
|
98
|
+
def duplicate options = {}
|
99
|
+
options = deep_hash_normalize options
|
100
|
+
super(
|
101
|
+
{
|
102
|
+
project_id: @project_id,
|
103
|
+
quota_project_id: @quota_project_id
|
104
|
+
}.merge(options)
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
88
108
|
# Revokes the credential
|
89
109
|
def revoke! options = {}
|
90
110
|
c = options[:connection] || Faraday.default_connection
|
@@ -114,6 +134,29 @@ module Google
|
|
114
134
|
Google::Auth::ScopeUtil.normalize(scope)
|
115
135
|
missing_scope.empty?
|
116
136
|
end
|
137
|
+
|
138
|
+
# Destructively updates these credentials
|
139
|
+
#
|
140
|
+
# This method is called by `Signet::OAuth2::Client`'s constructor
|
141
|
+
#
|
142
|
+
# @param options [Hash] Overrides for the credentials parameters.
|
143
|
+
# The following keys are recognized in addition to keys in the
|
144
|
+
# Signet::OAuth2::Client
|
145
|
+
# * `project_id` the project id to use during the authentication
|
146
|
+
# * `quota_project_id` the quota project id to use
|
147
|
+
# during the authentication
|
148
|
+
# @return [Google::Auth::UserRefreshCredentials]
|
149
|
+
def update! options = {}
|
150
|
+
# Normalize all keys to symbols to allow indifferent access.
|
151
|
+
options = deep_hash_normalize options
|
152
|
+
|
153
|
+
@project_id = options[:project_id] if options.key? :project_id
|
154
|
+
@quota_project_id = options[:quota_project_id] if options.key? :quota_project_id
|
155
|
+
|
156
|
+
super(options)
|
157
|
+
|
158
|
+
self
|
159
|
+
end
|
117
160
|
end
|
118
161
|
end
|
119
162
|
end
|