omniauth 2.3.0 → 2.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50ca36a1e5d776acd239b6bf0795352777cbbb9d4cfb6cdad37b35fa42071e30
4
- data.tar.gz: 5042edc2920336a00c334faeed9ef80957f83b470ce97c9dd79056d93ba11959
3
+ metadata.gz: bddaf279c8ba5ce1bca9cb80c8c5cd73b541b7dcda1c2adfde1767077b9233f4
4
+ data.tar.gz: bb344106bc102d803528d8070d046f2230f18a4d1deb000aff8f83459ffe3cd0
5
5
  SHA512:
6
- metadata.gz: b3a700e831fec507055fec1860e78be412699562972e2fddd79018d7a07dd0751d650d415bb8921c0207c593fe27871768d084742c6707bbb44bbf52f6222e86
7
- data.tar.gz: 0f7776d3a2446974cf8fc9a93f0eaae46c92b3c20becbf4321daa64dd50dd89dbf25d618fef2ac8acd22ee5e9ec586e2db075f62670e5f2344a5907931f10bf7
6
+ metadata.gz: 1630c220c39715558036324880226c351541be17bfaa86b772a3ad9e5c2d3e2049d971b158a25738dc228c6da614506dee69eecdd30391fddd61e9b431502f5f
7
+ data.tar.gz: 701902a0b78c8b62dea137fe0fafa811800a16f2cf8e29499e3ff7dc53a72165c4d779ff4098c312ca3382646742b8ff49c3098fb1b04c6bd9624083d1b69f50
data/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![JRuby](https://github.com/omniauth/omniauth/actions/workflows/jruby.yml/badge.svg)][githubactionsjruby]
7
7
  [![Maintainability](https://qlty.sh/gh/omniauth/projects/omniauth/maintainability.svg)][qlty]
8
8
  [![Coverage Status](http://img.shields.io/coveralls/omniauth/omniauth.svg)][coveralls]
9
+ [![Duel](https://raw.githubusercontent.com/omniauth/omniauth/duel/badge/badge.svg)](https://github.com/sferik/duel-ruby)
9
10
 
10
11
  [gem]: https://rubygems.org/gems/omniauth
11
12
  [githubactions]: https://github.com/omniauth/omniauth/actions/workflows/main.yml
@@ -15,7 +16,7 @@
15
16
  [coveralls]: https://coveralls.io/r/omniauth/omniauth
16
17
 
17
18
  This is the documentation for the in-development branch of OmniAuth.
18
- You can find the documentation for the latest stable release [here](https://github.com/omniauth/omniauth/tree/v2.3.0)
19
+ You can find the documentation for the latest stable release [here](https://github.com/omniauth/omniauth/tree/v2.4.0)
19
20
 
20
21
  ## An Introduction
21
22
  OmniAuth is a library that standardizes multi-provider authentication for
@@ -378,12 +379,41 @@ def callback_sign_in_key
378
379
  end
379
380
  ```
380
381
 
381
- OmniAuth then stores the origin and params of the latest 5 sign-ins under
382
+ OmniAuth then stores the origin and params of the latest 3 sign-ins under
382
383
  their keys in `session['omniauth.sign_ins']`, and gives each callback its
383
384
  own. It still sets `omniauth.origin` and `omniauth.params` in the session
384
385
  for the latest sign-in, and falls back to them when a callback's key isn't
385
386
  remembered.
386
387
 
388
+ The session is often a 4 KB cookie, so each sign-in stores only what it
389
+ needs: no origin when there isn't one, no params when they're empty, and
390
+ no params with more than 512 bytes in their keys and values. A callback
391
+ whose params were too large to keep gets empty `omniauth.params`, and
392
+ OmniAuth logs a warning.
393
+
394
+ A strategy that needs its own values in the callback, such as an OAuth 2.0
395
+ PKCE verifier, can store them in the same sign-in instead of under another
396
+ session key, so the key isn't stored twice. It passes them to
397
+ `remember_sign_in`, and the callback reads them from `remembered_sign_in`,
398
+ which is the sign-in stored for the callback's key, or `nil`:
399
+
400
+ ```ruby
401
+ def request_phase
402
+ state = SecureRandom.hex(24)
403
+ remember_sign_in(state, 'verifier' => verifier)
404
+ redirect authorize_url(state)
405
+ end
406
+
407
+ def callback_phase
408
+ return fail!(:csrf_detected) unless remembered_sign_in
409
+
410
+ exchange_code(remembered_sign_in['verifier'])
411
+ end
412
+ ```
413
+
414
+ The keys `origin` and `params` are OmniAuth's, so a strategy shouldn't
415
+ use them.
416
+
387
417
  ## Resources
388
418
  The [OmniAuth Wiki](https://github.com/omniauth/omniauth/wiki) has
389
419
  actively maintained in-depth documentation for OmniAuth. It should be
@@ -8,20 +8,35 @@ module OmniAuth
8
8
  # A strategy opts in when its provider sends a value from the request
9
9
  # phase back to the callback, such as OAuth 2.0's state. Its request
10
10
  # phase calls remember_sign_in with that value, and it overrides
11
- # callback_sign_in_key to return the value from the callback.
11
+ # callback_sign_in_key to return the value from the callback. It can
12
+ # store its own values for the callback in the same sign-in, such as
13
+ # OAuth 2.0's PKCE verifier, and read them back with
14
+ # remembered_sign_in.
15
+ #
16
+ # The sign-ins live in the session, which is often a 4 KB cookie, so
17
+ # each one stores only what isn't empty.
12
18
  module SignIns
13
19
  # How many sign-ins are remembered. Remembering another forgets the
14
20
  # oldest.
15
- MAX_REMEMBERED_SIGN_INS = 5
21
+ MAX_REMEMBERED_SIGN_INS = 3
22
+
23
+ # The largest params, counting the bytes of their keys and values, that
24
+ # a sign-in remembers. The params come from the request, so they can be any
25
+ # size, and a few large ones would overflow a cookie session.
26
+ MAX_REMEMBERED_PARAMS_BYTESIZE = 512
16
27
 
17
- # Remembers the origin and params stored for the current request phase
18
- # under key, which the provider sends back to the callback.
19
- def remember_sign_in(key)
28
+ # Remembers the current request phase's sign-in under key, which the
29
+ # provider sends back to the callback: the strategy's own data, plus
30
+ # the origin and params OmniAuth stored, under "origin" and "params",
31
+ # so data shouldn't use those keys.
32
+ def remember_sign_in(key, data = nil)
20
33
  return if key.empty?
21
34
 
35
+ sign_in = data ? data.dup : {} #: Hash[String, untyped]
36
+ add_origin_and_params(sign_in)
22
37
  sign_ins = remembered_sign_ins
23
38
  sign_ins.delete(key)
24
- sign_ins[key] = {'origin' => session['omniauth.origin'], 'params' => session.fetch('omniauth.params')}
39
+ sign_ins[key] = sign_in
25
40
  session['omniauth.sign_ins'] = sign_ins.to_a.last(MAX_REMEMBERED_SIGN_INS).to_h
26
41
  end
27
42
 
@@ -31,19 +46,28 @@ module OmniAuth
31
46
 
32
47
  protected
33
48
 
34
- # The origin and params for this callback, removed from the session:
35
- # the ones remembered under its key, or else omniauth.origin and
36
- # omniauth.params, which hold the latest sign-in's.
37
- def recall_sign_in
38
- fallback = {'origin' => session.delete('omniauth.origin'), 'params' => session.delete('omniauth.params')}
49
+ # The sign-in remembered under the callback's key, removed from the
50
+ # session, or nil if none is. It's the same for the whole callback,
51
+ # so the strategy can read its own data after OmniAuth takes the
52
+ # origin and params.
53
+ def remembered_sign_in
54
+ return @remembered_sign_in if instance_variable_defined?(:@remembered_sign_in)
55
+
39
56
  key = callback_sign_in_key
40
57
  sign_ins = remembered_sign_ins
41
58
  remembered = sign_ins.keys.detect { |stored| Rack::Utils.secure_compare(stored, key) } if key.instance_of?(String)
42
59
  sign_in = sign_ins.delete(remembered)
43
- return fallback unless sign_in
60
+ session['omniauth.sign_ins'] = sign_ins if sign_in
61
+ @remembered_sign_in = sign_in
62
+ end
44
63
 
45
- session['omniauth.sign_ins'] = sign_ins
46
- sign_in
64
+ # The origin and params for this callback, removed from the session:
65
+ # the ones remembered under its key, or else omniauth.origin and
66
+ # omniauth.params, which hold the latest sign-in's. Either may be
67
+ # missing.
68
+ def recall_sign_in
69
+ fallback = {'origin' => session.delete('omniauth.origin'), 'params' => session.delete('omniauth.params')}
70
+ remembered_sign_in || fallback
47
71
  end
48
72
 
49
73
  # A copy of the remembered sign-ins, which are changed by assigning the
@@ -52,6 +76,43 @@ module OmniAuth
52
76
  sign_ins = session['omniauth.sign_ins']
53
77
  sign_ins.is_a?(Hash) ? sign_ins.dup : {}
54
78
  end
79
+
80
+ private
81
+
82
+ # Adds the origin and params stored for the current request phase to
83
+ # sign_in, leaving out an empty origin, empty params, and params too
84
+ # large to keep.
85
+ def add_origin_and_params(sign_in)
86
+ origin = session['omniauth.origin']
87
+ params = session['omniauth.params']
88
+ sign_in['origin'] = origin unless origin.nil? || origin.empty?
89
+ sign_in['params'] = params if params && !params.empty? && params_small_enough?(params)
90
+ end
91
+
92
+ def params_small_enough?(params)
93
+ bytesize = params_bytesize(params)
94
+ small_enough = bytesize <= MAX_REMEMBERED_PARAMS_BYTESIZE
95
+ log :warn, "Not remembering #{bytesize} bytes of params for this sign-in, more than #{MAX_REMEMBERED_PARAMS_BYTESIZE}. The callback gets empty omniauth.params." unless small_enough
96
+ small_enough
97
+ end
98
+
99
+ # The bytes in the keys and values of params, nested hashes and arrays
100
+ # included. It's counted without building a string, since every
101
+ # request phase with params counts them.
102
+ def params_bytesize(value)
103
+ case value
104
+ when Hash
105
+ total = 0
106
+ value.each_pair { |key, nested| total += key.to_s.bytesize + params_bytesize(nested) }
107
+ total
108
+ when Array
109
+ total = 0
110
+ value.each { |nested| total += params_bytesize(nested) }
111
+ total
112
+ else
113
+ value.to_s.bytesize
114
+ end
115
+ end
55
116
  end
56
117
  end
57
118
  end
@@ -1,3 +1,4 @@
1
+ require 'cgi/escape'
1
2
  require 'omniauth/key_store'
2
3
  require 'omniauth/strategy/class_methods'
3
4
  require 'omniauth/strategy/fetchers'
@@ -208,10 +209,13 @@ module OmniAuth
208
209
  # referer) so the callback phase can send them back there.
209
210
  def store_origin
210
211
  origin = request.params[options.origin_param]
211
- referer = env['HTTP_REFERER']
212
212
  if origin
213
- session['omniauth.origin'] = origin
214
- elsif referer && !referer.match?(/#{request_path}$/)
213
+ # A present origin param is what the app asked for, so never silently
214
+ # fall back to the referer when it's rejected; just store nothing. It
215
+ # must be a String: an array (origin[]=...) would pass the scheme check
216
+ # through its to_s and still land in the session.
217
+ session['omniauth.origin'] = origin if origin.is_a?(String) && safe_origin?(origin)
218
+ elsif (referer = referer_origin)
215
219
  session['omniauth.origin'] = referer
216
220
  end
217
221
  end
@@ -223,9 +227,9 @@ module OmniAuth
223
227
  return invalid_host! unless allowed_host?
224
228
 
225
229
  sign_in = recall_sign_in
226
- origin = sign_in.fetch('origin')
230
+ origin = sign_in['origin']
227
231
  env['omniauth.origin'] = (origin unless origin.eql?(''))
228
- env['omniauth.params'] = sign_in.fetch('params') || {}
232
+ env['omniauth.params'] = sign_in['params'] || {}
229
233
  run_hook(:before_callback_phase)
230
234
  callback_phase
231
235
  end
@@ -282,12 +286,16 @@ module OmniAuth
282
286
  end
283
287
 
284
288
  def redirect(uri)
289
+ uri = ascii_compatible_uri(uri)
285
290
  r = Rack::Response.new
291
+ r['content-type'] = 'text/html'
286
292
 
287
- if options[:iframe]
288
- r.write("<script type='text/javascript' charset='utf-8'>top.location.href = '#{uri}';</script>")
293
+ # A disallowed scheme (javascript:, data:, ...) never reaches the inline
294
+ # script; it falls through to the 302, where a Location header is inert.
295
+ if options[:iframe] && allowed_redirect_uri?(uri)
296
+ r.write("<script type='text/javascript' charset='utf-8'>top.location.href = '#{escape_javascript(uri)}';</script>")
289
297
  else
290
- r.write("Redirecting to #{uri}...")
298
+ r.write("Redirecting to #{CGI.escapeHTML(uri.to_s)}...")
291
299
  r.redirect(uri)
292
300
  end
293
301
 
@@ -316,6 +324,91 @@ module OmniAuth
316
324
 
317
325
  protected
318
326
 
327
+ # Characters that could end the single-quoted string literal or the
328
+ # <script> element the :iframe redirect writes the URI into.
329
+ JAVASCRIPT_UNSAFE = /[\x00-\x1f"&'<>\\\u2028\u2029]/.freeze
330
+ private_constant :JAVASCRIPT_UNSAFE
331
+
332
+ # Rewrites each unsafe character as a \uXXXX escape, which the browser
333
+ # decodes back to that character inside the string literal. Binary strings
334
+ # (which Rack hands back for query strings and the like) are read as UTF-8
335
+ # so multibyte characters survive, any other encoding is transcoded so a
336
+ # non-UTF-8 charset isn't misread, and only invalid bytes are replaced.
337
+ # A valid UTF-8 string goes straight through without being copied.
338
+ def escape_javascript(value)
339
+ string = value.to_s
340
+ string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding.equal?(Encoding::BINARY)
341
+ string = string.encode(Encoding::UTF_8, :invalid => :replace, :undef => :replace) unless string.encoding.equal?(Encoding::UTF_8)
342
+ string = string.scrub unless string.valid_encoding?
343
+ string.gsub(JAVASCRIPT_UNSAFE) { |char| format('\\u%04x', char.ord) }
344
+ end
345
+
346
+ # Tab/newline/CR anywhere, or a leading control byte or space, which a
347
+ # browser strips before it reads the scheme.
348
+ NEEDS_NORMALIZING = /[\t\r\n]|\A[\x00-\x20]/.freeze
349
+ private_constant :NEEDS_NORMALIZING
350
+
351
+ # Schemes a browser runs as script when it follows the URL. Escaping can't
352
+ # neutralize one (none of its characters are unsafe), so such a URI must
353
+ # never reach the inline :iframe script or be persisted as an origin.
354
+ DANGEROUS_SCHEME = /\A(?:javascript|vbscript|data):/i.freeze
355
+ private_constant :DANGEROUS_SCHEME
356
+
357
+ ANY_SCHEME = /\A[a-z][a-z0-9+.-]*:/i.freeze
358
+ private_constant :ANY_SCHEME
359
+
360
+ WEB_SCHEME = /\Ahttps?:/i.freeze
361
+ private_constant :WEB_SCHEME
362
+
363
+ # The URI as a browser reads its scheme: tab/newline/CR dropped anywhere
364
+ # and leading control bytes and spaces trimmed, so a split or padded scheme
365
+ # can't slip past the checks. A URI with none of those is returned as-is,
366
+ # so the common case allocates nothing. Invalid bytes are scrubbed first,
367
+ # since matching a regexp against them raises ArgumentError.
368
+ def normalize_redirect_uri(uri)
369
+ uri = uri.to_s
370
+ uri = uri.scrub unless uri.valid_encoding?
371
+ return uri unless uri.match?(NEEDS_NORMALIZING)
372
+
373
+ uri.gsub(/[\t\r\n]/, '').sub(/\A[\x00-\x20]+/, '')
374
+ end
375
+
376
+ # Safe to write into the inline :iframe script: a relative/protocol-relative
377
+ # path or an http(s) URL. A javascript:/data:/vbscript: URI is excluded here
378
+ # because escaping leaves it able to run in the application's origin.
379
+ def allowed_redirect_uri?(uri)
380
+ uri = normalize_redirect_uri(uri)
381
+ !uri.match?(ANY_SCHEME) || uri.match?(WEB_SCHEME)
382
+ end
383
+
384
+ # Safe to persist as an origin: anything but a scheme that runs as script.
385
+ # Unlike the iframe check this keeps non-web deep links such as myapp://,
386
+ # which mobile sign-in relies on, while still refusing a URI that a later
387
+ # redirect to omniauth.origin could execute.
388
+ def safe_origin?(uri)
389
+ !normalize_redirect_uri(uri).match?(DANGEROUS_SCHEME)
390
+ end
391
+
392
+ # The referer to fall back on as the origin, unless it is the request path
393
+ # itself or has a scheme that runs as script. It's scrubbed before any
394
+ # matching, which would otherwise raise on invalid bytes.
395
+ def referer_origin
396
+ referer = env['HTTP_REFERER']
397
+ return unless referer
398
+
399
+ referer = referer.scrub unless referer.valid_encoding?
400
+ referer if !referer.end_with?(request_path) && safe_origin?(referer)
401
+ end
402
+
403
+ # A URI in an encoding that isn't ASCII-compatible, such as UTF-16, can't
404
+ # be matched against the scheme checks or written into the UTF-8 response,
405
+ # so it's transcoded to UTF-8 first. Anything else is returned as-is.
406
+ def ascii_compatible_uri(uri)
407
+ return uri unless uri.is_a?(String) && !uri.encoding.ascii_compatible?
408
+
409
+ uri.encode(Encoding::UTF_8, :invalid => :replace, :undef => :replace)
410
+ end
411
+
319
412
  # Re-raises errors from the downstream app; reports anything else
320
413
  # raised during a phase as an authentication failure.
321
414
  def handle_phase_error(error)
@@ -1,3 +1,3 @@
1
1
  module OmniAuth
2
- VERSION = '2.3.0'.freeze
2
+ VERSION = '2.4.0'.freeze
3
3
  end
data/sig/omniauth.rbs CHANGED
@@ -459,11 +459,16 @@ module OmniAuth
459
459
  # A strategy opts in by calling remember_sign_in in its request phase
460
460
  # and overriding callback_sign_in_key.
461
461
  module SignIns : _Core
462
+ @remembered_sign_in: Hash[String, untyped]?
463
+
462
464
  MAX_REMEMBERED_SIGN_INS: Integer
463
465
 
464
- # Remembers the current request phase's origin and params under key,
465
- # which the provider sends back to the callback.
466
- def remember_sign_in: (String key) -> void
466
+ MAX_REMEMBERED_PARAMS_BYTESIZE: Integer
467
+
468
+ # Remembers the current request phase's sign-in under key, which the
469
+ # provider sends back to the callback: the strategy's own data, plus
470
+ # the origin and params under "origin" and "params".
471
+ def remember_sign_in: (String key, ?Hash[String, untyped]? data) -> void
467
472
 
468
473
  # The key the callback sends back, or nil for a strategy that doesn't
469
474
  # remember sign-ins.
@@ -471,11 +476,21 @@ module OmniAuth
471
476
 
472
477
  private
473
478
 
479
+ # The sign-in remembered under the callback's key, removed from the
480
+ # session, or nil if none is.
481
+ def remembered_sign_in: () -> Hash[String, untyped]?
482
+
474
483
  # The "origin" and "params" for this callback, removed from the
475
484
  # session.
476
485
  def recall_sign_in: () -> Hash[String, untyped]
477
486
 
478
487
  def remembered_sign_ins: () -> Hash[String, Hash[String, untyped]]
488
+
489
+ def add_origin_and_params: (Hash[String, untyped] sign_in) -> void
490
+
491
+ def params_small_enough?: (Hash[String, untyped] params) -> bool
492
+
493
+ def params_bytesize: (untyped value) -> Integer
479
494
  end
480
495
 
481
496
  # Runs the request and callback phases against OmniAuth.config.mock_auth
@@ -568,6 +583,28 @@ module OmniAuth
568
583
 
569
584
  private
570
585
 
586
+ JAVASCRIPT_UNSAFE: Regexp
587
+
588
+ NEEDS_NORMALIZING: Regexp
589
+
590
+ DANGEROUS_SCHEME: Regexp
591
+
592
+ ANY_SCHEME: Regexp
593
+
594
+ WEB_SCHEME: Regexp
595
+
596
+ def escape_javascript: (_ToS value) -> String
597
+
598
+ def normalize_redirect_uri: (_ToS uri) -> String
599
+
600
+ def allowed_redirect_uri?: (_ToS uri) -> bool
601
+
602
+ def safe_origin?: (_ToS uri) -> bool
603
+
604
+ def referer_origin: () -> String?
605
+
606
+ def ascii_compatible_uri: [T < Object] (T uri) -> (T | String)
607
+
571
608
  def handle_phase_error: (StandardError error) -> rack_response
572
609
 
573
610
  def run_hook: (Symbol name) -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Berlin
@@ -124,7 +124,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
124
124
  - !ruby/object:Gem::Version
125
125
  version: '0'
126
126
  requirements: []
127
- rubygems_version: 4.0.20
127
+ rubygems_version: 3.6.9
128
128
  specification_version: 4
129
129
  summary: A generalized Rack framework for multiple-provider authentication.
130
130
  test_files: []