omniauth-google-oauth2 1.2.1 → 1.2.3

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.
@@ -2,36 +2,28 @@
2
2
 
3
3
  # Google's OAuth2 docs. Make sure you are familiar with all the options
4
4
  # before attempting to configure this gem.
5
- # https://developers.google.com/accounts/docs/OAuth2Login
5
+ # https://developers.google.com/identity/protocols/oauth2
6
6
 
7
7
  Rails.application.config.middleware.use OmniAuth::Builder do
8
8
  # Default usage, this will give you offline access and a refresh token
9
9
  # using default scopes 'email' and 'profile'
10
10
  #
11
- provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], scope: 'email, profile'
11
+ provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], scope: 'email, profile'
12
12
 
13
13
  # Custom redirect_uri
14
14
  #
15
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], scope: 'email, profile', redirect_uri: 'https://localhost:3000/redirect'
15
+ # provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], scope: 'email, profile', redirect_uri: 'https://localhost:3000/redirect'
16
16
 
17
17
  # Manual setup for offline access with a refresh token.
18
18
  #
19
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], access_type: 'offline'
19
+ # provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], access_type: 'offline'
20
20
 
21
- # Custom scope supporting youtube. If you are customizing scopes, remember
21
+ # Custom scope supporting YouTube. If you are customizing scopes, remember
22
22
  # to include the default scopes 'email' and 'profile'
23
23
  #
24
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], scope: 'http://gdata.youtube.com, email, profile, plus.me'
24
+ # provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], scope: 'https://www.googleapis.com/auth/youtube.readonly, email, profile'
25
25
 
26
26
  # Custom scope for users only using Google for account creation/auth and do not require a refresh token.
27
27
  #
28
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], access_type: 'online', prompt: ''
29
-
30
- # To include information about people in your circles you must include the 'plus.login' scope.
31
- #
32
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], skip_friends: false, scope: 'email, profile, plus.login'
33
-
34
- # If you need to acquire whether user picture is a default one or uploaded by user.
35
- #
36
- # provider :google_oauth2, ENV['GOOGLE_KEY'], ENV['GOOGLE_SECRET'], skip_image_info: false
28
+ # provider :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'], access_type: 'online', prompt: ''
37
29
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAuth
4
4
  module GoogleOauth2
5
- VERSION = '1.2.1'
5
+ VERSION = '1.2.3'
6
6
  end
7
7
  end
@@ -3,6 +3,8 @@
3
3
  require 'jwt'
4
4
  require 'oauth2'
5
5
  require 'omniauth/strategies/oauth2'
6
+ require 'openssl'
7
+ require 'stringio'
6
8
  require 'uri'
7
9
 
8
10
  module OmniAuth
@@ -14,12 +16,77 @@ module OmniAuth
14
16
  BASE_SCOPES = %w[profile email openid].freeze
15
17
  DEFAULT_SCOPE = 'email,profile'
16
18
  USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
17
- IMAGE_SIZE_REGEXP = /(s\d+(-c)?)|(w\d+-h\d+(-c)?)|(w\d+(-c)?)|(h\d+(-c)?)|c/
19
+ JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'
20
+ JWKS_CACHE_TTL = 3600
21
+ JWKS_RETRY_INTERVAL = 60
22
+ LOG_MESSAGE_LIMIT = 200
23
+ REQUIRED_ID_TOKEN_CLAIMS = %w[iss aud exp sub].freeze
18
24
  AUTHORIZE_OPTIONS = %i[access_type hd login_hint prompt request_visible_actions scope state redirect_uri include_granted_scopes enable_granular_consent openid_realm device_id device_name]
19
25
 
26
+ JwksUnavailable = Class.new(StandardError)
27
+
28
+ @jwks_mutex = Mutex.new
29
+
30
+ class << self
31
+ # Google's signing keys rotate, so the key set is shared across requests
32
+ # and refetched on expiry or when a token names a kid we do not hold.
33
+ # The fetch deliberately happens under the lock: concurrent callers wait
34
+ # for one request rather than each issuing their own.
35
+ # The keys belong to Google, not to any one strategy class, so a subclass
36
+ # shares this cache rather than keeping a second one and refetching the
37
+ # same data. Delegating also keeps it from reaching for a mutex it does
38
+ # not own, which is only defined here.
39
+ def cached_jwks(force: false, &fetch)
40
+ return GoogleOauth2.cached_jwks(force: force, &fetch) unless equal?(GoogleOauth2)
41
+
42
+ @jwks_mutex.synchronize do
43
+ now = ::Time.now.to_i
44
+
45
+ # A forced refresh means some token named a kid we do not hold, which
46
+ # the sender chooses freely. Honour it no more often than the retry
47
+ # interval, or it becomes a way to drive unlimited fetches, each one
48
+ # holding this lock while every other login waits.
49
+ force &&= @jwks_forced_at.nil? || now >= @jwks_forced_at + JWKS_RETRY_INTERVAL
50
+
51
+ if force || @jwks.nil? || now >= @jwks_expires_at.to_i
52
+ # Back off even with nothing cached. Otherwise an unreachable
53
+ # endpoint queues every waiting caller behind its own timeout,
54
+ # since the lock serializes them and no expiry has been recorded.
55
+ raise JwksUnavailable, 'within JWKS retry backoff' if @jwks.nil? && now < @jwks_retry_at.to_i
56
+
57
+ @jwks_forced_at = now if force
58
+
59
+ begin
60
+ @jwks = yield
61
+ @jwks_expires_at = ::Time.now.to_i + JWKS_CACHE_TTL
62
+ rescue StandardError
63
+ retry_at = ::Time.now.to_i + JWKS_RETRY_INTERVAL
64
+ @jwks_retry_at = retry_at
65
+ raise if @jwks.nil?
66
+
67
+ # Google's keys outlive this cache by a wide margin, so serve the
68
+ # stale set through a short outage rather than failing every
69
+ # login, and back off instead of refetching on each request.
70
+ @jwks_expires_at = retry_at
71
+ end
72
+ end
73
+ @jwks
74
+ end
75
+ end
76
+
77
+ def reset_jwks_cache!
78
+ return GoogleOauth2.reset_jwks_cache! unless equal?(GoogleOauth2)
79
+
80
+ @jwks_mutex.synchronize do
81
+ @jwks = nil
82
+ @jwks_expires_at = nil
83
+ @jwks_retry_at = nil
84
+ @jwks_forced_at = nil
85
+ end
86
+ end
87
+ end
88
+
20
89
  option :name, 'google_oauth2'
21
- option :skip_friends, true
22
- option :skip_image_info, true
23
90
  option :skip_jwt, false
24
91
  option :jwt_leeway, 60
25
92
  option :authorize_options, AUTHORIZE_OPTIONS
@@ -69,21 +136,9 @@ module OmniAuth
69
136
 
70
137
  extra do
71
138
  hash = {}
72
- token = nil_or_empty?(access_token['id_token']) ? access_token.token : access_token['id_token']
139
+ token = access_token['id_token']
73
140
  hash[:id_token] = token
74
- if !options[:skip_jwt] && !nil_or_empty?(token)
75
- decoded = ::JWT.decode(token, nil, false).first
76
-
77
- # We have to manually verify the claims because the third parameter to
78
- # JWT.decode is false since no verification key is provided.
79
- ::JWT::Claims.verify_payload!(decoded,
80
- iss: ALLOWED_ISSUERS,
81
- aud: options.client_id,
82
- exp: { leeway: options.jwt_leeway },
83
- nbf: { leeway: options.jwt_leeway })
84
-
85
- hash[:id_info] = decoded
86
- end
141
+ hash[:id_info] = id_token_claims(token) if !options[:skip_jwt] && !nil_or_empty?(token)
87
142
  hash[:raw_info] = raw_info unless skip_info?
88
143
  prune! hash
89
144
  end
@@ -95,6 +150,11 @@ module OmniAuth
95
150
  def custom_build_access_token
96
151
  access_token = get_access_token(request)
97
152
 
153
+ # Nothing in the request produced a usable credential. Raising here gives
154
+ # the caller an ordinary auth failure; the alternative is omniauth-oauth2
155
+ # calling #expired? on nil and the request dying with a NoMethodError.
156
+ raise CallbackError.new(:invalid_credentials, 'No valid credentials were supplied in the callback request') if access_token.nil?
157
+
98
158
  verify_hd(access_token)
99
159
  access_token
100
160
  end
@@ -107,6 +167,54 @@ module OmniAuth
107
167
  obj.is_a?(String) ? obj.empty? : obj.nil?
108
168
  end
109
169
 
170
+ # Failure messages quote the request: an unknown key names the caller's own
171
+ # kid header, and a parse error quotes the body. Left alone, a newline in
172
+ # either forges a log line, so bound the length and flatten control
173
+ # characters to keep one record per event.
174
+ def sanitize_for_log(message)
175
+ message.to_s.gsub(/[[:cntrl:]]/, ' ')[0, LOG_MESSAGE_LIMIT]
176
+ end
177
+
178
+ def trusted_client_ids
179
+ [options.client_id, *Array(options.authorized_client_ids)].compact.uniq
180
+ end
181
+
182
+ def id_token_claims(token)
183
+ return @id_token_claims if token == @id_token_claims_token && !@id_token_claims.nil?
184
+
185
+ clear_id_token_claims
186
+
187
+ # Safe to decode without a key only because every route into
188
+ # access_token['id_token'] has already established trust: the token
189
+ # endpoint delivers it over TLS from Google, and a caller-supplied one
190
+ # is cached here only after verified_id_token has checked its signature.
191
+ claims = ::JWT.decode(token, nil, false).first
192
+
193
+ # We have to manually verify the claims because the third parameter to
194
+ # JWT.decode is false since no verification key is provided.
195
+ # required is what makes the rest binding: the individual claim checks
196
+ # pass silently when a claim is simply absent, so without this a token
197
+ # missing exp would never expire.
198
+ ::JWT::Claims.verify_payload!(claims,
199
+ required: REQUIRED_ID_TOKEN_CLAIMS,
200
+ iss: ALLOWED_ISSUERS,
201
+ aud: trusted_client_ids,
202
+ exp: { leeway: options.jwt_leeway },
203
+ nbf: { leeway: options.jwt_leeway })
204
+
205
+ cache_id_token_claims(token, claims)
206
+ end
207
+
208
+ def cache_id_token_claims(token, claims)
209
+ @id_token_claims_token = token
210
+ @id_token_claims = claims
211
+ end
212
+
213
+ def clear_id_token_claims
214
+ @id_token_claims_token = nil
215
+ @id_token_claims = nil
216
+ end
217
+
110
218
  def callback_url
111
219
  options[:redirect_uri] || (full_host + callback_path)
112
220
  end
@@ -120,25 +228,138 @@ module OmniAuth
120
228
  elsif verifier
121
229
  client_get_token(verifier, redirect_uri || callback_url)
122
230
  elsif access_token && verify_token(access_token)
123
- ::OAuth2::AccessToken.from_hash(client, request.params.dup)
231
+ ::OAuth2::AccessToken.from_hash(client, direct_token_hash(access_token, request.params['id_token']))
124
232
  elsif request.content_type =~ /json/i
125
233
  begin
126
- body = JSON.parse(request.body.read)
127
- request.body.rewind # rewind request body for downstream middlewares
234
+ raw_body = request.body.read
235
+ # Rack 3 input streams are not required to be rewindable, so hand
236
+ # downstream middlewares a fresh stream rather than rewinding this one.
237
+ request.env['rack.input'] = StringIO.new(raw_body).tap(&:binmode)
238
+ body = JSON.parse(raw_body)
239
+ # Valid JSON that is not an object carries no credential, and
240
+ # indexing it by string would raise rather than fall through.
241
+ body = nil unless body.is_a?(Hash)
128
242
  verifier = body && body['code']
129
243
  access_token = body && body['access_token']
130
244
  redirect_uri ||= body && body['redirect_uri']
131
245
  if verifier
132
246
  client_get_token(verifier, redirect_uri || 'postmessage')
133
247
  elsif verify_token(access_token)
134
- ::OAuth2::AccessToken.from_hash(client, body.dup)
248
+ ::OAuth2::AccessToken.from_hash(client, direct_token_hash(access_token, body['id_token']))
135
249
  end
136
250
  rescue JSON::ParserError => e
137
- warn "[omniauth google-oauth2] JSON parse error=#{e}"
251
+ warn "[omniauth google-oauth2] JSON parse error=#{sanitize_for_log(e)}"
138
252
  end
139
253
  end
140
254
  end
141
255
 
256
+ # An id_token supplied by the caller is only as trustworthy as its
257
+ # signature, so it is carried through only once Google has vouched for it.
258
+ # Nothing else from the request is: refresh_token and expiry cannot be
259
+ # verified at all, so they stay dropped.
260
+ def direct_token_hash(access_token, raw_id_token)
261
+ hash = { 'access_token' => access_token }
262
+ id_token = verified_id_token(raw_id_token, access_token)
263
+ hash['id_token'] = id_token if id_token
264
+ hash
265
+ end
266
+
267
+ def verified_id_token(raw_id_token, access_token)
268
+ clear_id_token_claims
269
+ return nil if nil_or_empty?(raw_id_token)
270
+
271
+ # Widening this list means revisiting at_hash_matches?, which hardcodes
272
+ # SHA-256 because at_hash is defined over the digest named by the token's
273
+ # own alg header.
274
+ claims = ::JWT.decode(raw_id_token, nil, true,
275
+ algorithms: ['RS256'],
276
+ jwks: ->(opts) { google_jwks(force: opts[:invalidate]) },
277
+ required_claims: REQUIRED_ID_TOKEN_CLAIMS,
278
+ iss: ALLOWED_ISSUERS, verify_iss: true,
279
+ aud: trusted_client_ids, verify_aud: true,
280
+ verify_expiration: true, exp_leeway: options.jwt_leeway,
281
+ verify_not_before: true, nbf_leeway: options.jwt_leeway).first
282
+ return nil unless same_user?(claims, access_token)
283
+
284
+ cache_id_token_claims(raw_id_token, claims)
285
+ raw_id_token
286
+ rescue StandardError => e
287
+ # Fail closed. A token we cannot verify, for any reason including the
288
+ # key set being unreachable, is discarded rather than trusted.
289
+ warn "[omniauth google-oauth2] discarding unverified id_token: #{e.class}: #{sanitize_for_log(e.message)}"
290
+ nil
291
+ end
292
+
293
+ # A signature proves Google issued the id_token, not that it describes the
294
+ # same person as the access token it arrived beside. Without that, a
295
+ # genuine id_token for one user could be presented with another user's
296
+ # access token, leaving uid and extra.id_info describing different people.
297
+ def same_user?(claims, access_token)
298
+ return true if at_hash_matches?(claims, access_token)
299
+
300
+ subjects_match?(claims, access_token)
301
+ end
302
+
303
+ # at_hash ties an id_token to one specific access token: the left half of
304
+ # the SHA-256 of that token, base64url encoded. SHA-256 because the digest
305
+ # follows the token's alg header, which the decode above pins to RS256.
306
+ # Only a fast path, so absence and mismatch are both just "unproven here",
307
+ # deferring to the subject check rather than deciding anything.
308
+ def at_hash_matches?(claims, access_token)
309
+ expected = claims['at_hash']
310
+ return false if nil_or_empty?(expected)
311
+
312
+ # Encoded with pack rather than Base64, which is a bundled gem as of
313
+ # Ruby 3.4 and would become a dependency of every host application.
314
+ # Compared with ==, not a constant-time helper: both sides derive from
315
+ # the access token the caller just sent, so there is no secret to leak,
316
+ # and OpenSSL.secure_compare does not exist before Ruby 2.7.
317
+ digest = ::OpenSSL::Digest::SHA256.digest(access_token)[0, 16]
318
+ [digest].pack('m0').tr('+/', '-_').delete('=') == expected
319
+ end
320
+
321
+ # The claim that actually matters: does the id_token describe the same
322
+ # person the access token belongs to? Comparing subjects tests that
323
+ # directly, where at_hash only tests it by proxy. It also stays correct
324
+ # when a client refreshes its access token but forwards the id_token it
325
+ # was originally issued, which is a legitimate at_hash mismatch.
326
+ def subjects_match?(claims, access_token)
327
+ subject = claims['sub']
328
+ return false if nil_or_empty?(subject)
329
+ return true if subject == userinfo_for(access_token)['sub']
330
+
331
+ warn '[omniauth google-oauth2] discarding id_token: subject does not match the access token'
332
+ false
333
+ end
334
+
335
+ # Memoized into the same ivar raw_info and verify_hd use, so the lookup is
336
+ # shared rather than repeated. skip_info still governs whether any of this
337
+ # reaches the auth hash; it trims output, it does not waive the check.
338
+ def userinfo_for(access_token)
339
+ @raw_info ||= ::OAuth2::AccessToken.from_hash(client, 'access_token' => access_token).get(USER_INFO_URL).parsed
340
+ end
341
+
342
+ def google_jwks(force: false)
343
+ # Anchored to this class rather than self.class so that subclasses share
344
+ # the one cache instead of each looking for state they do not own.
345
+ GoogleOauth2.cached_jwks(force: force) do
346
+ # Parsed from the body rather than the response wrapper, which warns
347
+ # about the JWKS "keys" entry colliding with a built-in Hash method.
348
+ ::JWT::JWK::Set.new(parse_jwks(client.request(:get, JWKS_URL).body))
349
+ end
350
+ end
351
+
352
+ # Google returns a small, fixed shape. Anything else is treated as an
353
+ # outage rather than coerced, since JWT::JWK::Set will happily build a key
354
+ # set out of surprising input, including an HMAC key from a bare string.
355
+ def parse_jwks(body)
356
+ parsed = JSON.parse(body)
357
+ raise JwksUnavailable, 'JWKS response was not an object' unless parsed.is_a?(Hash)
358
+ raise JwksUnavailable, 'JWKS response had no keys array' unless parsed['keys'].is_a?(Array)
359
+
360
+ parsed
361
+ end
362
+
142
363
  def client_get_token(verifier, redirect_uri)
143
364
  client.auth_code.get_token(verifier, get_token_options(redirect_uri), get_token_params)
144
365
  end
@@ -149,7 +370,7 @@ module OmniAuth
149
370
 
150
371
  def get_scope(params)
151
372
  raw_scope = params[:scope] || DEFAULT_SCOPE
152
- scope_list = raw_scope.split(' ').map { |item| item.split(',') }.flatten
373
+ scope_list = raw_scope.split.map { |item| item.split(',') }.flatten
153
374
  scope_list.map! { |s| s =~ %r{^https?://} || BASE_SCOPES.include?(s) ? s : "#{BASE_SCOPE_URL}#{s}" }
154
375
  scope_list.join(' ')
155
376
  end
@@ -212,8 +433,8 @@ module OmniAuth
212
433
  # strip `sz` parameter (defaults to sz=50) which overrides `image_size` options
213
434
  return nil if query_parameters.nil?
214
435
 
215
- params = CGI.parse(query_parameters)
216
- stripped_params = params.delete_if { |key| key == 'sz' }
436
+ params = URI.decode_www_form(query_parameters)
437
+ stripped_params = params.delete_if { |key, _value| key == 'sz' }
217
438
 
218
439
  # don't return an empty Hash since that would result
219
440
  # in URLs with a trailing ? character: http://image.url?
@@ -225,8 +446,11 @@ module OmniAuth
225
446
  def token_info(access_token)
226
447
  return nil unless access_token
227
448
 
449
+ # Keyed on k, not on the access_token this method was first called with:
450
+ # one request can ask about more than one token, and closing over the
451
+ # first would file that answer under every later key.
228
452
  @token_info ||= Hash.new do |h, k|
229
- h[k] = client.request(:post, 'https://www.googleapis.com/oauth2/v3/tokeninfo', body: { access_token: access_token }).parsed
453
+ h[k] = client.request(:post, 'https://www.googleapis.com/oauth2/v3/tokeninfo', body: { access_token: k }).parsed
230
454
  end
231
455
 
232
456
  @token_info[access_token]
@@ -236,7 +460,7 @@ module OmniAuth
236
460
  return false unless access_token
237
461
 
238
462
  token_info = token_info(access_token)
239
- token_info['aud'] == options.client_id || options.authorized_client_ids.include?(token_info['aud'])
463
+ trusted_client_ids.include?(token_info['aud'])
240
464
  end
241
465
 
242
466
  def verify_hd(access_token)
@@ -9,8 +9,8 @@ Gem::Specification.new do |gem|
9
9
  gem.name = 'omniauth-google-oauth2'
10
10
  gem.version = OmniAuth::GoogleOauth2::VERSION
11
11
  gem.license = 'MIT'
12
- gem.summary = %(A Google OAuth2 strategy for OmniAuth 1.x)
13
- gem.description = %(A Google OAuth2 strategy for OmniAuth 1.x. This allows you to login to Google with your ruby app.)
12
+ gem.summary = %(A Google OAuth2 strategy for OmniAuth)
13
+ gem.description = %(A Google OAuth2 strategy for OmniAuth. This allows you to login to Google with your ruby app.)
14
14
  gem.authors = ['Josh Ellithorpe', 'Yury Korolev']
15
15
  gem.email = ['quest@mac.com']
16
16
  gem.homepage = 'https://github.com/zquestz/omniauth-google-oauth2'
@@ -20,12 +20,11 @@ Gem::Specification.new do |gem|
20
20
 
21
21
  gem.required_ruby_version = '>= 2.5'
22
22
 
23
- gem.add_runtime_dependency 'jwt', '>= 2.9.2'
24
- gem.add_runtime_dependency 'oauth2', '~> 2.0'
25
- gem.add_runtime_dependency 'omniauth', '~> 2.0'
26
- gem.add_runtime_dependency 'omniauth-oauth2', '~> 1.8'
23
+ gem.add_dependency 'jwt', '>= 2.9.2', '< 4'
24
+ gem.add_dependency 'oauth2', '~> 2.0'
25
+ gem.add_dependency 'omniauth', '~> 2.0'
26
+ gem.add_dependency 'omniauth-oauth2', '~> 1.8'
27
27
 
28
- gem.add_development_dependency 'rake', '~> 12.0'
28
+ gem.add_development_dependency 'rake', '~> 13.3'
29
29
  gem.add_development_dependency 'rspec', '~> 3.6'
30
- gem.add_development_dependency 'rubocop', '~> 0.49'
31
30
  end