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.
- checksums.yaml +4 -4
- data/.github/workflows/rubocop.yml +1 -1
- data/CHANGELOG.md +166 -4
- data/README.md +77 -45
- data/examples/config.ru +42 -23
- data/lib/omniauth/google_oauth2/version.rb +1 -1
- data/lib/omniauth/strategies/google_oauth2.rb +248 -21
- data/omniauth-google-oauth2.gemspec +1 -1
- data/spec/omniauth/strategies/google_oauth2_spec.rb +588 -19
- data/spec/spec_helper.rb +9 -0
- metadata +7 -1
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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'
|