googleauth 1.3.0 → 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.
@@ -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"
@@ -39,7 +40,11 @@ module Google
39
40
  attr_reader :quota_project_id
40
41
 
41
42
  def enable_self_signed_jwt?
42
- @enable_self_signed_jwt
43
+ # Use a self-singed JWT if there's no information that can be used to
44
+ # obtain an OAuth token, OR if there are scopes but also an assertion
45
+ # that they are default scopes that shouldn't be used to fetch a token,
46
+ # OR we are not in the default universe and thus OAuth isn't supported.
47
+ target_audience.nil? && (scope.nil? || @enable_self_signed_jwt || universe_domain != "googleapis.com")
43
48
  end
44
49
 
45
50
  # Creates a ServiceAccountCredentials.
@@ -53,12 +58,13 @@ module Google
53
58
  raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience
54
59
 
55
60
  if json_key_io
56
- private_key, client_email, project_id, quota_project_id = read_json_key json_key_io
61
+ private_key, client_email, project_id, quota_project_id, universe_domain = read_json_key json_key_io
57
62
  else
58
63
  private_key = unescape ENV[CredentialsLoader::PRIVATE_KEY_VAR]
59
64
  client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
60
65
  project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
61
66
  quota_project_id = nil
67
+ universe_domain = nil
62
68
  end
63
69
  project_id ||= CredentialsLoader.load_gcloud_project_id
64
70
 
@@ -70,10 +76,35 @@ module Google
70
76
  issuer: client_email,
71
77
  signing_key: OpenSSL::PKey::RSA.new(private_key),
72
78
  project_id: project_id,
73
- quota_project_id: quota_project_id)
79
+ quota_project_id: quota_project_id,
80
+ universe_domain: universe_domain || "googleapis.com")
74
81
  .configure_connection(options)
75
82
  end
76
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
+
77
108
  # Handles certain escape sequences that sometimes appear in input.
78
109
  # Specifically, interprets the "\n" sequence for newline, and removes
79
110
  # enclosing quotes.
@@ -93,16 +124,44 @@ module Google
93
124
  # Extends the base class to use a transient
94
125
  # ServiceAccountJwtHeaderCredentials for certain cases.
95
126
  def apply! a_hash, opts = {}
96
- # Use a self-singed JWT if there's no information that can be used to
97
- # obtain an OAuth token, OR if there are scopes but also an assertion
98
- # that they are default scopes that shouldn't be used to fetch a token.
99
- if target_audience.nil? && (scope.nil? || enable_self_signed_jwt?)
127
+ if enable_self_signed_jwt?
100
128
  apply_self_signed_jwt! a_hash
101
129
  else
102
130
  super
103
131
  end
104
132
  end
105
133
 
134
+ # Modifies this logic so it also requires self-signed-jwt to be disabled
135
+ def needs_access_token?
136
+ super && !enable_self_signed_jwt?
137
+ end
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
+
106
165
  private
107
166
 
108
167
  def apply_self_signed_jwt! a_hash
@@ -115,6 +174,7 @@ module Google
115
174
  }
116
175
  key_io = StringIO.new MultiJson.dump(cred_json)
117
176
  alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io, scope: scope
177
+ alt.logger = logger
118
178
  alt.apply! a_hash
119
179
  end
120
180
  end
@@ -130,14 +190,18 @@ module Google
130
190
  # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
131
191
  class ServiceAccountJwtHeaderCredentials
132
192
  JWT_AUD_URI_KEY = :jwt_aud_uri
133
- AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY
193
+ AUTH_METADATA_KEY = Google::Auth::BaseClient::AUTH_METADATA_KEY
134
194
  TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
135
195
  SIGNING_ALGORITHM = "RS256".freeze
136
196
  EXPIRY = 60
197
+
137
198
  extend CredentialsLoader
138
199
  extend JsonKeyReader
200
+
139
201
  attr_reader :project_id
140
202
  attr_reader :quota_project_id
203
+ attr_accessor :universe_domain
204
+ attr_accessor :logger
141
205
 
142
206
  # Create a ServiceAccountJwtHeaderCredentials.
143
207
  #
@@ -154,17 +218,46 @@ module Google
154
218
  def initialize options = {}
155
219
  json_key_io = options[:json_key_io]
156
220
  if json_key_io
157
- @private_key, @issuer, @project_id, @quota_project_id =
221
+ @private_key, @issuer, @project_id, @quota_project_id, @universe_domain =
158
222
  self.class.read_json_key json_key_io
159
223
  else
160
- @private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
161
- @issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
162
- @project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
163
- @quota_project_id = nil
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
164
229
  end
230
+ @universe_domain ||= "googleapis.com"
165
231
  @project_id ||= CredentialsLoader.load_gcloud_project_id
166
232
  @signing_key = OpenSSL::PKey::RSA.new @private_key
167
- @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
168
261
  end
169
262
 
170
263
  # Construct a jwt token if the JWT_AUD_URI key is present in the input
@@ -176,10 +269,14 @@ module Google
176
269
  return a_hash if jwt_aud_uri.nil? && @scope.nil?
177
270
  jwt_token = new_jwt_token jwt_aud_uri, opts
178
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
179
276
  a_hash
180
277
  end
181
278
 
182
- # Returns a clone of a_hash updated with the authoriation header
279
+ # Returns a clone of a_hash updated with the authorization header
183
280
  def apply a_hash, opts = {}
184
281
  a_copy = a_hash.clone
185
282
  apply! a_copy, opts
@@ -192,8 +289,6 @@ module Google
192
289
  proc { |a_hash, opts = {}| apply a_hash, opts }
193
290
  end
194
291
 
195
- protected
196
-
197
292
  # Creates a jwt uri token.
198
293
  def new_jwt_token jwt_aud_uri = nil, options = {}
199
294
  now = Time.new
@@ -210,8 +305,34 @@ module Google
210
305
  assertion["scope"] = Array(@scope).join " " if @scope
211
306
  assertion["aud"] = jwt_aud_uri if jwt_aud_uri
212
307
 
308
+ logger&.debug do
309
+ Google::Logging::Message.from message: "JWT assertion: #{assertion}"
310
+ end
311
+
213
312
  JWT.encode assertion, @signing_key, SIGNING_ALGORITHM
214
313
  end
314
+
315
+ # Duck-types the corresponding method from BaseClient
316
+ def needs_access_token?
317
+ false
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
215
336
  end
216
337
  end
217
338
  end
@@ -12,58 +12,61 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
+ require "base64"
16
+ require "json"
15
17
  require "signet/oauth_2/client"
18
+ require "googleauth/base_client"
16
19
 
17
20
  module Signet
18
21
  # OAuth2 supports OAuth2 authentication.
19
22
  module OAuth2
20
- AUTH_METADATA_KEY = :authorization
21
23
  # Signet::OAuth2::Client creates an OAuth2 client
22
24
  #
23
25
  # This reopens Client to add #apply and #apply! methods which update a
24
26
  # hash with the fetched authentication token.
25
27
  class Client
26
- def configure_connection options
27
- @connection_info =
28
- options[:connection_builder] || options[:default_connection]
28
+ include Google::Auth::BaseClient
29
+
30
+ alias update_token_signet_base update_token!
31
+
32
+ def update_token! options = {}
33
+ options = deep_hash_normalize options
34
+ id_token_expires_at = expires_at_from_id_token options[:id_token]
35
+ options[:expires_at] = id_token_expires_at if id_token_expires_at
36
+ update_token_signet_base options
37
+ self.universe_domain = options[:universe_domain] if options.key? :universe_domain
29
38
  self
30
39
  end
31
40
 
32
- # The token type as symbol, either :id_token or :access_token
33
- def token_type
34
- target_audience ? :id_token : :access_token
35
- end
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
36
45
 
37
- # Whether the id_token or access_token is missing or about to expire.
38
- def needs_access_token?
39
- send(token_type).nil? || expires_within?(60)
40
- end
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
41
53
 
42
- # Updates a_hash updated with the authentication token
43
- def apply! a_hash, opts = {}
44
- # fetch the access token there is currently not one, or if the client
45
- # has expired
46
- fetch_access_token! opts if needs_access_token?
47
- a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}"
54
+ update_signet_base options
48
55
  end
49
56
 
50
- # Returns a clone of a_hash updated with the authentication token
51
- def apply a_hash, opts = {}
52
- a_copy = a_hash.clone
53
- apply! a_copy, opts
54
- a_copy
57
+ def configure_connection options
58
+ @connection_info =
59
+ options[:connection_builder] || options[:default_connection]
60
+ self
55
61
  end
56
62
 
57
- # Returns a reference to the #apply method, suitable for passing as
58
- # a closure
59
- def updater_proc
60
- proc { |a_hash, opts = {}| apply a_hash, opts }
63
+ # The token type as symbol, either :id_token or :access_token
64
+ def token_type
65
+ target_audience ? :id_token : :access_token
61
66
  end
62
67
 
63
- def on_refresh &block
64
- @refresh_listeners = [] unless defined? @refresh_listeners
65
- @refresh_listeners << block
66
- end
68
+ # Set the universe domain
69
+ attr_accessor :universe_domain
67
70
 
68
71
  alias orig_fetch_access_token! fetch_access_token!
69
72
  def fetch_access_token! options = {}
@@ -78,11 +81,22 @@ module Signet
78
81
  info
79
82
  end
80
83
 
81
- def notify_refresh_listeners
82
- listeners = defined?(@refresh_listeners) ? @refresh_listeners : []
83
- listeners.each do |block|
84
- block.call self
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
+ )
85
98
  end
99
+ parameters
86
100
  end
87
101
 
88
102
  def build_default_connection
@@ -99,20 +113,117 @@ module Signet
99
113
  retry_count = 0
100
114
 
101
115
  begin
102
- yield
116
+ yield.tap { |resp| log_response resp }
103
117
  rescue StandardError => e
104
- raise e if e.is_a?(Signet::AuthorizationError) || e.is_a?(Signet::ParseError)
118
+ if e.is_a?(Signet::AuthorizationError) || e.is_a?(Signet::ParseError)
119
+ log_auth_error e
120
+ raise e
121
+ end
105
122
 
106
123
  if retry_count < max_retry_count
124
+ log_transient_error e
107
125
  retry_count += 1
108
126
  sleep retry_count * 0.3
109
127
  retry
110
128
  else
129
+ log_retries_exhausted e
111
130
  msg = "Unexpected error: #{e.inspect}"
112
131
  raise Signet::AuthorizationError, msg
113
132
  end
114
133
  end
115
134
  end
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
+
172
+ private
173
+
174
+ def expires_at_from_id_token id_token
175
+ match = /^[\w=-]+\.([\w=-]+)\.[\w=-]+$/.match id_token.to_s
176
+ return unless match
177
+ json = JSON.parse Base64.urlsafe_decode64 match[1]
178
+ return unless json.key? "exp"
179
+ Time.at json["exp"].to_i
180
+ rescue StandardError
181
+ # Shouldn't happen unless we get a garbled ID token
182
+ nil
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
116
227
  end
117
228
  end
118
229
  end
@@ -29,7 +29,7 @@ module Google
29
29
  # @return [String]
30
30
  # The loaded token data.
31
31
  def load _id
32
- raise "Not implemented"
32
+ raise NoMethodError, "load not implemented"
33
33
  end
34
34
 
35
35
  # Put the token data into storage for the given ID.
@@ -39,7 +39,7 @@ module Google
39
39
  # @param [String] token
40
40
  # The token data to store.
41
41
  def store _id, _token
42
- raise "Not implemented"
42
+ raise NoMethodError, "store not implemented"
43
43
  end
44
44
 
45
45
  # Remove the token data from storage for the given ID.
@@ -47,7 +47,7 @@ module Google
47
47
  # @param [String] id
48
48
  # ID of the token data to delete
49
49
  def delete _id
50
- raise "Not implemented"
50
+ raise NoMethodError, "delete not implemented"
51
51
  end
52
52
  end
53
53
  end
@@ -16,6 +16,7 @@ require "uri"
16
16
  require "multi_json"
17
17
  require "googleauth/signet"
18
18
  require "googleauth/user_refresh"
19
+ require "securerandom"
19
20
 
20
21
  module Google
21
22
  module Auth
@@ -54,17 +55,26 @@ module Google
54
55
  # Authorization scope to request
55
56
  # @param [Google::Auth::Stores::TokenStore] token_store
56
57
  # Backing storage for persisting user credentials
57
- # @param [String] callback_uri
58
+ # @param [String] legacy_callback_uri
58
59
  # URL (either absolute or relative) of the auth callback.
59
- # Defaults to '/oauth2callback'
60
- def initialize client_id, scope, token_store, callback_uri = nil
60
+ # Defaults to '/oauth2callback'.
61
+ # @deprecated This field is deprecated. Instead, use the keyword
62
+ # argument callback_uri.
63
+ # @param [String] code_verifier
64
+ # Random string of 43-128 chars used to verify the key exchange using
65
+ # PKCE.
66
+ def initialize client_id, scope, token_store,
67
+ legacy_callback_uri = nil,
68
+ callback_uri: nil,
69
+ code_verifier: nil
61
70
  raise NIL_CLIENT_ID_ERROR if client_id.nil?
62
71
  raise NIL_SCOPE_ERROR if scope.nil?
63
72
 
64
73
  @client_id = client_id
65
74
  @scope = Array(scope)
66
75
  @token_store = token_store
67
- @callback_uri = callback_uri || "/oauth2callback"
76
+ @callback_uri = legacy_callback_uri || callback_uri || "/oauth2callback"
77
+ @code_verifier = code_verifier
68
78
  end
69
79
 
70
80
  # Build the URL for requesting authorization.
@@ -80,14 +90,29 @@ module Google
80
90
  # @param [String, Array<String>] scope
81
91
  # Authorization scope to request. Overrides the instance scopes if not
82
92
  # nil.
93
+ # @param [Hash] additional_parameters
94
+ # Additional query parameters to be added to the authorization URL.
83
95
  # @return [String]
84
96
  # Authorization url
85
97
  def get_authorization_url options = {}
86
98
  scope = options[:scope] || @scope
99
+
100
+ options[:additional_parameters] ||= {}
101
+
102
+ if @code_verifier
103
+ options[:additional_parameters].merge!(
104
+ {
105
+ code_challenge: generate_code_challenge(@code_verifier),
106
+ code_challenge_method: code_challenge_method
107
+ }
108
+ )
109
+ end
110
+
87
111
  credentials = UserRefreshCredentials.new(
88
112
  client_id: @client_id.id,
89
113
  client_secret: @client_id.secret,
90
- scope: scope
114
+ scope: scope,
115
+ additional_parameters: options[:additional_parameters]
91
116
  )
92
117
  redirect_uri = redirect_uri_for options[:base_url]
93
118
  url = credentials.authorization_uri(access_type: "offline",
@@ -144,6 +169,9 @@ module Google
144
169
  # Absolute URL to resolve the configured callback uri against.
145
170
  # Required if the configured
146
171
  # callback uri is a relative.
172
+ # @param [Hash] additional_parameters
173
+ # Additional parameters to be added to the post body of token
174
+ # endpoint request.
147
175
  # @return [Google::Auth::UserRefreshCredentials]
148
176
  # Credentials if exchange is successful
149
177
  def get_credentials_from_code options = {}
@@ -151,11 +179,14 @@ module Google
151
179
  code = options[:code]
152
180
  scope = options[:scope] || @scope
153
181
  base_url = options[:base_url]
182
+ options[:additional_parameters] ||= {}
183
+ options[:additional_parameters].merge!({ code_verifier: @code_verifier })
154
184
  credentials = UserRefreshCredentials.new(
155
- client_id: @client_id.id,
156
- client_secret: @client_id.secret,
157
- redirect_uri: redirect_uri_for(base_url),
158
- scope: scope
185
+ client_id: @client_id.id,
186
+ client_secret: @client_id.secret,
187
+ redirect_uri: redirect_uri_for(base_url),
188
+ scope: scope,
189
+ additional_parameters: options[:additional_parameters]
159
190
  )
160
191
  credentials.code = code
161
192
  credentials.fetch_access_token!({})
@@ -221,6 +252,23 @@ module Google
221
252
  credentials
222
253
  end
223
254
 
255
+ # The code verifier for PKCE for OAuth 2.0. When set, the
256
+ # authorization URI will contain the Code Challenge and Code
257
+ # Challenge Method querystring parameters, and the token URI will
258
+ # contain the Code Verifier parameter.
259
+ #
260
+ # @param [String|nil] new_code_erifier
261
+ def code_verifier= new_code_verifier
262
+ @code_verifier = new_code_verifier
263
+ end
264
+
265
+ # Generate the code verifier needed to be sent while fetching
266
+ # authorization URL.
267
+ def self.generate_code_verifier
268
+ random_number = rand 32..96
269
+ SecureRandom.alphanumeric random_number
270
+ end
271
+
224
272
  private
225
273
 
226
274
  # @private Fetch stored token with given user_id
@@ -265,6 +313,15 @@ module Google
265
313
  def uri_is_postmessage? uri
266
314
  uri.to_s.casecmp("postmessage").zero?
267
315
  end
316
+
317
+ def generate_code_challenge code_verifier
318
+ digest = Digest::SHA256.digest code_verifier
319
+ Base64.urlsafe_encode64 digest, padding: false
320
+ end
321
+
322
+ def code_challenge_method
323
+ "S256"
324
+ end
268
325
  end
269
326
  end
270
327
  end