omniauth-google-oauth2 1.2.2 → 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.
@@ -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,8 +16,76 @@ 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'
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
17
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]
18
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
+
19
89
  option :name, 'google_oauth2'
20
90
  option :skip_jwt, false
21
91
  option :jwt_leeway, 60
@@ -66,21 +136,9 @@ module OmniAuth
66
136
 
67
137
  extra do
68
138
  hash = {}
69
- token = nil_or_empty?(access_token['id_token']) ? access_token.token : access_token['id_token']
139
+ token = access_token['id_token']
70
140
  hash[:id_token] = token
71
- if !options[:skip_jwt] && !nil_or_empty?(token)
72
- decoded = ::JWT.decode(token, nil, false).first
73
-
74
- # We have to manually verify the claims because the third parameter to
75
- # JWT.decode is false since no verification key is provided.
76
- ::JWT::Claims.verify_payload!(decoded,
77
- iss: ALLOWED_ISSUERS,
78
- aud: options.client_id,
79
- exp: { leeway: options.jwt_leeway },
80
- nbf: { leeway: options.jwt_leeway })
81
-
82
- hash[:id_info] = decoded
83
- end
141
+ hash[:id_info] = id_token_claims(token) if !options[:skip_jwt] && !nil_or_empty?(token)
84
142
  hash[:raw_info] = raw_info unless skip_info?
85
143
  prune! hash
86
144
  end
@@ -92,6 +150,11 @@ module OmniAuth
92
150
  def custom_build_access_token
93
151
  access_token = get_access_token(request)
94
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
+
95
158
  verify_hd(access_token)
96
159
  access_token
97
160
  end
@@ -104,6 +167,54 @@ module OmniAuth
104
167
  obj.is_a?(String) ? obj.empty? : obj.nil?
105
168
  end
106
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
+
107
218
  def callback_url
108
219
  options[:redirect_uri] || (full_host + callback_path)
109
220
  end
@@ -117,25 +228,138 @@ module OmniAuth
117
228
  elsif verifier
118
229
  client_get_token(verifier, redirect_uri || callback_url)
119
230
  elsif access_token && verify_token(access_token)
120
- ::OAuth2::AccessToken.from_hash(client, request.params.dup)
231
+ ::OAuth2::AccessToken.from_hash(client, direct_token_hash(access_token, request.params['id_token']))
121
232
  elsif request.content_type =~ /json/i
122
233
  begin
123
- body = JSON.parse(request.body.read)
124
- 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)
125
242
  verifier = body && body['code']
126
243
  access_token = body && body['access_token']
127
244
  redirect_uri ||= body && body['redirect_uri']
128
245
  if verifier
129
246
  client_get_token(verifier, redirect_uri || 'postmessage')
130
247
  elsif verify_token(access_token)
131
- ::OAuth2::AccessToken.from_hash(client, body.dup)
248
+ ::OAuth2::AccessToken.from_hash(client, direct_token_hash(access_token, body['id_token']))
132
249
  end
133
250
  rescue JSON::ParserError => e
134
- warn "[omniauth google-oauth2] JSON parse error=#{e}"
251
+ warn "[omniauth google-oauth2] JSON parse error=#{sanitize_for_log(e)}"
135
252
  end
136
253
  end
137
254
  end
138
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
+
139
363
  def client_get_token(verifier, redirect_uri)
140
364
  client.auth_code.get_token(verifier, get_token_options(redirect_uri), get_token_params)
141
365
  end
@@ -222,8 +446,11 @@ module OmniAuth
222
446
  def token_info(access_token)
223
447
  return nil unless access_token
224
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.
225
452
  @token_info ||= Hash.new do |h, k|
226
- 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
227
454
  end
228
455
 
229
456
  @token_info[access_token]
@@ -233,7 +460,7 @@ module OmniAuth
233
460
  return false unless access_token
234
461
 
235
462
  token_info = token_info(access_token)
236
- token_info['aud'] == options.client_id || options.authorized_client_ids.include?(token_info['aud'])
463
+ trusted_client_ids.include?(token_info['aud'])
237
464
  end
238
465
 
239
466
  def verify_hd(access_token)
@@ -20,7 +20,7 @@ Gem::Specification.new do |gem|
20
20
 
21
21
  gem.required_ruby_version = '>= 2.5'
22
22
 
23
- gem.add_dependency 'jwt', '>= 2.9.2'
23
+ gem.add_dependency 'jwt', '>= 2.9.2', '< 4'
24
24
  gem.add_dependency 'oauth2', '~> 2.0'
25
25
  gem.add_dependency 'omniauth', '~> 2.0'
26
26
  gem.add_dependency 'omniauth-oauth2', '~> 1.8'