better_auth 0.8.0 → 0.11.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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -4,11 +4,18 @@ require "json"
4
4
 
5
5
  module BetterAuth
6
6
  class RateLimiter
7
- MISSING_CLIENT_IP_WARNING = "Rate limiting skipped: could not determine client IP address. " \
8
- "Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed."
7
+ MISSING_CLIENT_IP = "no-trusted-ip"
8
+ MEMORY_STORE_MAX_ENTRIES = 100_000
9
+ DATABASE_CONSUME_ATTEMPTS = 8
10
+ LEGACY_STORAGE_WARNING = "Rate limiting is best-effort: the configured storage has no atomic `consume`, so concurrent requests may bypass the limit. Provide a storage that implements `consume` for strict enforcement."
11
+ MISSING_CLIENT_IP_WARNING = "Rate limiting could not determine a trustworthy client IP address and is falling back to a " \
12
+ "single shared per-path bucket. Ensure your runtime forwards a trusted client IP header, then configure " \
13
+ "`advanced.ip_address.ip_address_headers` and, for proxy chains, `advanced.ip_address.trusted_proxies`."
9
14
 
10
15
  class MemoryStore
11
- def initialize
16
+ def initialize(clock: -> { Time.now.to_f }, max_entries: MEMORY_STORE_MAX_ENTRIES)
17
+ @clock = clock
18
+ @max_entries = max_entries
12
19
  @entries = {}
13
20
  @mutex = Mutex.new
14
21
  end
@@ -18,7 +25,7 @@ module BetterAuth
18
25
  entry = @entries[key]
19
26
  return nil unless entry
20
27
 
21
- if Time.now.to_f >= entry[:expires_at]
28
+ if clock.call > entry[:expires_at]
22
29
  @entries.delete(key)
23
30
  return nil
24
31
  end
@@ -29,188 +36,342 @@ module BetterAuth
29
36
 
30
37
  def set(key, value, ttl:, update: false)
31
38
  @mutex.synchronize do
32
- @entries[key] = {
33
- data: value,
34
- expires_at: Time.now.to_f + ttl.to_f
35
- }
39
+ @entries.delete(key)
40
+ @entries[key] = {data: value, expires_at: clock.call + ttl.to_f}
41
+ prune!(sweep: @entries.size > @max_entries)
36
42
  end
37
43
  end
44
+
45
+ def consume(key, window:, max:)
46
+ @mutex.synchronize do
47
+ now = clock.call
48
+ prune!(now, sweep: true)
49
+ entry = @entries[key]
50
+ data = entry[:data] if entry && now <= entry[:expires_at]
51
+ decision = RateLimiter.decide_consume(data, window: window, max: max, now: now)
52
+ if decision[:allowed]
53
+ @entries.delete(key)
54
+ @entries[key] = {data: decision.fetch(:next).merge(key: key), expires_at: now + window.to_f}
55
+ prune!(now)
56
+ end
57
+ decision.slice(:allowed, :retry_after)
58
+ end
59
+ end
60
+
61
+ def size
62
+ @mutex.synchronize { @entries.size }
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :clock
68
+
69
+ def prune!(now = clock.call, sweep: false)
70
+ @entries.delete_if { |_key, entry| now > entry[:expires_at] } if sweep
71
+ overflow = @entries.size - @max_entries
72
+ overflow.times { @entries.delete(@entries.first.first) } if overflow.positive?
73
+ end
74
+ end
75
+
76
+ def self.decide_consume(data, window:, max:, now:)
77
+ return {next: {key: "", count: 1, last_request: now}, allowed: true, retry_after: nil} unless data
78
+
79
+ last_request = data.fetch(:last_request).to_f
80
+ if now - last_request > window.to_f
81
+ return {next: data.merge(count: 1, last_request: now), allowed: true, retry_after: nil}
82
+ end
83
+ if data.fetch(:count).to_i >= max.to_i
84
+ return {
85
+ next: data,
86
+ allowed: false,
87
+ retry_after: [(last_request + window.to_f - now).ceil, 0].max
88
+ }
89
+ end
90
+
91
+ {
92
+ next: data.merge(count: data.fetch(:count).to_i + 1, last_request: now),
93
+ allowed: true,
94
+ retry_after: nil
95
+ }
38
96
  end
39
97
 
40
- def initialize
41
- @memory_store = MemoryStore.new
98
+ def initialize(clock: -> { Time.now.to_f }, memory_store: nil)
99
+ @clock = clock
100
+ @memory_store = memory_store || MemoryStore.new(clock: clock)
42
101
  @warned_missing_client_ip = false
102
+ @warned_legacy_storage = false
103
+ @warning_mutex = Mutex.new
43
104
  end
44
105
 
45
106
  def call(request, context, path)
46
107
  config = context.rate_limit_config || {}
47
108
  return unless config[:enabled]
109
+ return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
48
110
 
49
111
  ip = client_ip(request, context.options)
50
- warn_missing_client_ip(context) unless ip
51
- return unless ip
52
-
112
+ unless ip
113
+ warn_missing_client_ip(context)
114
+ ip = MISSING_CLIENT_IP
115
+ end
53
116
  rule = rate_limit_rule(request, context, config, path)
54
117
  return if rule == false
55
118
 
56
- window = rule[:window] || 10
57
- max = rule[:max] || 100
119
+ window = (rule[:window] || 10).to_f
120
+ max = (rule[:max] || 100).to_i
58
121
  key = rate_limit_key(ip, path)
59
- now = Time.now.to_f
60
- storage = storage_for(context, config)
61
- data = read_storage(storage, key)
122
+ decision = consume(storage_for(context, config), key, window: window, max: max, context: context)
123
+ return if decision[:allowed]
62
124
 
63
- unless data
64
- write_storage(storage, key, rate_limit_data(key, 1, now), ttl: window, update: false)
65
- return
125
+ rate_limit_response(decision[:retry_after] || window)
126
+ end
127
+
128
+ private
129
+
130
+ attr_reader :clock
131
+
132
+ def consume((type, storage), key, window:, max:, context:)
133
+ case type
134
+ when :memory then storage.consume(key, window: window, max: max)
135
+ when :database then consume_database(storage, key, window: window, max: max, context: context)
136
+ when :secondary then consume_secondary(storage, key, window: window, max: max, context: context)
137
+ when :custom
138
+ if storage.respond_to?(:consume)
139
+ normalize_consume_result(storage.consume(key, window: window, max: max))
140
+ else
141
+ legacy_consume(storage, key, window: window, max: max, context: context)
142
+ end
66
143
  end
144
+ end
67
145
 
68
- last_request = data.fetch(:last_request).to_f
69
- count = data.fetch(:count).to_i
70
- if should_rate_limit?(max.to_i, window.to_f, count, last_request, now)
71
- return rate_limit_response(retry_after(last_request, window.to_f, now))
146
+ def consume_database(adapter, key, window:, max:, context:)
147
+ DATABASE_CONSUME_ATTEMPTS.times do
148
+ now = clock.call
149
+ now_ms = (now * 1000).to_i
150
+ data = read_database_storage(adapter, key)
151
+ unless data
152
+ created = adapter.create_if_absent(
153
+ model: "rateLimit",
154
+ data: {key: key, count: 1, lastRequest: now_ms},
155
+ conflict_field: "key"
156
+ )
157
+ return {allowed: true, retry_after: nil} if created
158
+ next
159
+ end
160
+
161
+ last_request = data.fetch(:last_request).to_f
162
+ if now - last_request > window
163
+ reset = adapter.increment_one(
164
+ model: "rateLimit",
165
+ where: [
166
+ {field: "key", value: key},
167
+ {field: "lastRequest", operator: "lte", value: (last_request * 1000).to_i}
168
+ ],
169
+ increment: {},
170
+ set: {count: 1, lastRequest: now_ms}
171
+ )
172
+ if reset
173
+ schedule_database_cleanup(adapter, context, now_ms)
174
+ return {allowed: true, retry_after: nil}
175
+ end
176
+ next
177
+ end
178
+
179
+ incremented = adapter.increment_one(
180
+ model: "rateLimit",
181
+ where: [
182
+ {field: "key", value: key},
183
+ {field: "lastRequest", operator: "gte", value: ((now - window) * 1000).to_i},
184
+ {field: "count", operator: "lt", value: max}
185
+ ],
186
+ increment: {count: 1},
187
+ set: {lastRequest: now_ms}
188
+ )
189
+ return {allowed: true, retry_after: nil} if incremented
190
+
191
+ fresh = read_database_storage(adapter, key)
192
+ next unless fresh
193
+ next if now - fresh.fetch(:last_request).to_f > window
194
+
195
+ return {allowed: false, retry_after: retry_after(fresh.fetch(:last_request).to_f, window, now)}
72
196
  end
73
197
 
74
- next_data = if now - last_request > window.to_f
75
- rate_limit_data(key, 1, now)
76
- else
77
- rate_limit_data(key, count + 1, now)
198
+ data = read_database_storage(adapter, key)
199
+ return {allowed: false, retry_after: window.ceil} unless data
200
+
201
+ {allowed: false, retry_after: retry_after(data.fetch(:last_request).to_f, window, clock.call)}
202
+ end
203
+
204
+ def consume_secondary(storage, key, window:, max:, context:)
205
+ if storage.respond_to?(:increment)
206
+ existing = storage.get(key)
207
+ legacy_data = parse_secondary_legacy_data(existing)
208
+ if legacy_data
209
+ return legacy_consume(
210
+ storage, key, window: window, max: max, context: context, secondary: true, data: legacy_data
211
+ )
212
+ end
213
+
214
+ count = storage.increment(key, window)
215
+ return {allowed: count.to_i <= max, retry_after: (count.to_i > max) ? window.ceil : nil}
78
216
  end
79
217
 
80
- write_storage(storage, key, next_data, ttl: window, update: true)
218
+ legacy_consume(storage, key, window: window, max: max, context: context, secondary: true)
219
+ end
220
+
221
+ def legacy_consume(storage, key, window:, max:, context:, secondary: false, data: nil)
222
+ warn_legacy_storage(context)
223
+ data ||= read_legacy_storage(storage, key, secondary: secondary)
224
+ decision = self.class.decide_consume(data, window: window, max: max, now: clock.call)
225
+ return decision.slice(:allowed, :retry_after) unless decision[:allowed]
226
+
227
+ next_data = decision.fetch(:next).merge(key: key)
228
+ value = secondary ? JSON.generate(secondary_storage_data(next_data)) : next_data
229
+ call_legacy_set(storage, key, value, ttl: window, update: !data.nil?, secondary: secondary)
230
+ decision.slice(:allowed, :retry_after)
231
+ end
232
+
233
+ def normalize_consume_result(result)
234
+ invalid_consume!("must return a Hash") unless result.is_a?(Hash)
235
+ allowed = result.key?(:allowed) ? result[:allowed] : result["allowed"]
236
+ retry_after = result.key?(:retry_after) ? result[:retry_after] : result["retry_after"]
237
+ invalid_consume!("must include boolean allowed") unless allowed == true || allowed == false
238
+ valid_retry_after = retry_after.nil? || (retry_after.is_a?(Numeric) && retry_after.finite? && retry_after >= 0)
239
+ unless valid_retry_after
240
+ invalid_consume!("retry_after must be nil or a finite nonnegative number")
241
+ end
242
+ {allowed: allowed, retry_after: retry_after&.ceil}
243
+ end
244
+
245
+ def invalid_consume!(message)
246
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid rate limit consume result: #{message}")
247
+ end
248
+
249
+ def parse_secondary_legacy_data(value)
250
+ return normalize_rate_limit_data(symbolize_keys(value)) if value.is_a?(Hash)
251
+ return nil unless value.is_a?(String) && value.lstrip.start_with?("{")
252
+
253
+ parsed = JSON.parse(value)
254
+ normalize_rate_limit_data(symbolize_keys(parsed)) if parsed.is_a?(Hash)
255
+ rescue JSON::ParserError
81
256
  nil
82
257
  end
83
258
 
84
- private
259
+ def read_legacy_storage(storage, key, secondary:)
260
+ data = storage.get(key)
261
+ data = JSON.parse(data) if secondary && data.is_a?(String)
262
+ normalize_rate_limit_data(symbolize_keys(data))
263
+ rescue JSON::ParserError
264
+ nil
265
+ end
266
+
267
+ def call_legacy_set(storage, key, value, ttl:, update:, secondary:)
268
+ parameters = storage.method(:set).parameters
269
+ keyword_capable = parameters.any? { |type, _name| [:key, :keyreq].include?(type) }
270
+ if !secondary && keyword_capable
271
+ storage.set(key, value, ttl: ttl, update: update)
272
+ elsif secondary
273
+ storage.set(key, value, ttl)
274
+ else
275
+ storage.set(key, value, ttl, update)
276
+ end
277
+ end
278
+
279
+ def read_database_storage(adapter, key)
280
+ data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
281
+ normalize_rate_limit_data(symbolize_keys(data))
282
+ end
283
+
284
+ def schedule_database_cleanup(adapter, context, now_ms)
285
+ longest_window = longest_static_window(context)
286
+ return unless longest_window
287
+
288
+ task = lambda do
289
+ adapter.delete_many(
290
+ model: "rateLimit",
291
+ where: [{field: "lastRequest", operator: "lt", value: now_ms - (longest_window * 1000).to_i}]
292
+ )
293
+ rescue => error
294
+ log(context.logger, :error, "Error pruning rate limit rows", error)
295
+ end
296
+ context.run_in_background(task)
297
+ rescue => error
298
+ log(context.logger, :error, "Error scheduling rate limit row pruning", error)
299
+ end
300
+
301
+ def longest_static_window(context)
302
+ config = context.rate_limit_config || {}
303
+ custom_rules = config[:custom_rules] || {}
304
+ return nil if custom_rules.values.any? { |rule| rule.respond_to?(:call) }
305
+
306
+ windows = [(config[:window] || 10).to_f, 10, 60]
307
+ context.options.plugins.each do |plugin|
308
+ Array(plugin[:rate_limit]).each do |rule|
309
+ window = rule[:window]
310
+ return nil if window.respond_to?(:call)
311
+ windows << window.to_f if window
312
+ end
313
+ end
314
+ custom_rules.each_value do |rule|
315
+ next if rule == false
316
+
317
+ window = rule[:window]
318
+ return nil if window.respond_to?(:call)
319
+ windows << window.to_f if window
320
+ end
321
+ windows.max
322
+ end
85
323
 
86
324
  def rate_limit_response(retry_after)
87
325
  [
88
326
  429,
89
- {"content-type" => "application/json", "x-retry-after" => retry_after.to_s},
327
+ {"content-type" => "application/json", "x-retry-after" => retry_after.ceil.to_s},
90
328
  [JSON.generate({message: "Too many requests. Please try again later."})]
91
329
  ]
92
330
  end
93
331
 
94
- def should_rate_limit?(max, window, count, last_request, now)
95
- now - last_request < window && count >= max
96
- end
97
-
98
332
  def retry_after(last_request, window, now)
99
333
  [(last_request + window - now).ceil, 0].max
100
334
  end
101
335
 
102
- def rate_limit_data(key, count, last_request)
103
- {
104
- key: key,
105
- count: count,
106
- last_request: last_request
107
- }
108
- end
109
-
110
336
  def rate_limit_rule(request, context, config, path)
111
- rule = {
112
- window: config[:window] || 10,
113
- max: config[:max] || 100
114
- }
337
+ rule = {window: config[:window] || 10, max: config[:max] || 100}
115
338
  rule = default_special_rule(path) || rule
116
339
  rule = matching_plugin_rule(context, path) || rule
117
340
  custom_rule = matching_custom_rule(config, path)
118
341
  return resolve_custom_rule(custom_rule, request, rule) unless custom_rule.nil?
119
-
120
342
  rule
121
343
  end
122
344
 
123
345
  def default_special_rule(path)
124
- return unless path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
125
-
126
- {window: 10, max: 3}
346
+ return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
347
+ return {window: 60, max: 3} if path == "/request-password-reset" ||
348
+ path == "/send-verification-email" || path.start_with?("/forget-password") ||
349
+ path == "/email-otp/send-verification-otp" || path == "/email-otp/request-password-reset"
350
+ nil
127
351
  end
128
352
 
129
353
  def matching_custom_rule(config, path)
130
354
  custom_rules = config[:custom_rules] || {}
131
- custom_rules.find do |pattern, _rule|
132
- path_matches?(pattern.to_s, path)
133
- end&.last
355
+ custom_rules.find { |pattern, _rule| path_matches?(pattern.to_s, path) }&.last
134
356
  end
135
357
 
136
358
  def resolve_custom_rule(rule, request, current)
137
359
  return false if rule == false
138
360
  return rule.call(request, current) if rule.respond_to?(:call)
139
-
140
361
  rule || current
141
362
  end
142
363
 
143
364
  def storage_for(context, config)
144
365
  return [:custom, config[:custom_storage]] if config[:custom_storage]
145
366
  return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
146
-
147
367
  if config[:storage] == "secondary-storage" && context.options.secondary_storage
148
368
  return [:secondary, context.options.secondary_storage]
149
369
  end
150
-
151
370
  [:memory, @memory_store]
152
371
  end
153
372
 
154
- def read_storage((type, storage), key)
155
- return read_database_storage(storage, key) if type == :database
156
-
157
- data = storage.get(key)
158
- data = JSON.parse(data) if type == :secondary && data.is_a?(String)
159
- normalize_rate_limit_data(symbolize_keys(data))
160
- rescue JSON::ParserError
161
- nil
162
- end
163
-
164
- def write_storage((type, storage), key, data, ttl:, update:)
165
- return write_database_storage(storage, key, data) if type == :database
166
-
167
- value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
168
- return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
169
-
170
- call_storage_set(storage, key, value, ttl: ttl, update: update)
171
- end
172
-
173
- def read_database_storage(adapter, key)
174
- data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
175
- normalize_rate_limit_data(symbolize_keys(data))
176
- end
177
-
178
- def write_database_storage(adapter, key, data)
179
- value = secondary_storage_data(data)
180
- existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
181
- if existing
182
- adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
183
- else
184
- adapter.create(model: "rateLimit", data: value)
185
- end
186
- end
187
-
188
373
  def secondary_storage_data(data)
189
- {
190
- key: data[:key],
191
- count: data[:count],
192
- lastRequest: (data[:last_request].to_f * 1000).to_i
193
- }
194
- end
195
-
196
- def call_secondary_storage_set(storage, key, value, ttl:, update:)
197
- storage.set(key, value, ttl)
198
- rescue ArgumentError
199
- call_storage_set(storage, key, value, ttl: ttl, update: update)
200
- end
201
-
202
- def call_storage_set(storage, key, value, ttl:, update:)
203
- storage.set(key, value, ttl: ttl, update: update)
204
- rescue ArgumentError
205
- begin
206
- storage.set(key, value, ttl, update)
207
- rescue ArgumentError
208
- begin
209
- storage.set(key, value, ttl)
210
- rescue ArgumentError
211
- storage.set(key, value)
212
- end
213
- end
374
+ {key: data[:key], count: data[:count], lastRequest: (data[:last_request].to_f * 1000).to_i}
214
375
  end
215
376
 
216
377
  def symbolize_keys(value)
@@ -226,7 +387,6 @@ module BetterAuth
226
387
 
227
388
  last_request = data[:last_request]
228
389
  return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
229
-
230
390
  data.merge(last_request: last_request / 1000.0)
231
391
  end
232
392
 
@@ -239,32 +399,39 @@ module BetterAuth
239
399
  end
240
400
 
241
401
  def warn_missing_client_ip(context)
242
- return if @warned_missing_client_ip
243
- return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
402
+ warn_once(:@warned_missing_client_ip) { log(context.logger, :warn, MISSING_CLIENT_IP_WARNING) }
403
+ end
404
+
405
+ def warn_legacy_storage(context)
406
+ warn_once(:@warned_legacy_storage) { log(context.logger, :warn, LEGACY_STORAGE_WARNING) }
407
+ end
408
+
409
+ def warn_once(variable)
410
+ @warning_mutex.synchronize do
411
+ return if instance_variable_get(variable)
244
412
 
245
- @warned_missing_client_ip = true
246
- logger = context.logger
413
+ instance_variable_set(variable, true)
414
+ yield
415
+ end
416
+ end
417
+
418
+ def log(logger, level, message, *arguments)
247
419
  if logger.respond_to?(:call)
248
- logger.call(:warn, MISSING_CLIENT_IP_WARNING)
249
- elsif logger.respond_to?(:warn)
250
- logger.warn(MISSING_CLIENT_IP_WARNING)
420
+ logger.call(level, message, *arguments)
421
+ elsif logger.respond_to?(level)
422
+ logger.public_send(level, message, *arguments)
251
423
  end
252
424
  end
253
425
 
254
426
  def matching_plugin_rule(context, path)
255
- context.options.plugins
256
- .flat_map { |plugin| Array(plugin[:rate_limit]) }
257
- .find do |rule|
258
- matcher = rule[:path_matcher]
259
- matcher&.call(path)
260
- end
427
+ context.options.plugins.flat_map { |plugin| Array(plugin[:rate_limit]) }
428
+ .find { |rule| rule[:path_matcher]&.call(path) }
261
429
  end
262
430
 
263
431
  def path_matches?(pattern, path)
264
432
  return path == pattern unless pattern.include?("*")
265
433
 
266
- regex = Regexp.escape(pattern).gsub("\\*", ".*")
267
- /\A#{regex}\z/.match?(path)
434
+ /\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z/.match?(path)
268
435
  end
269
436
  end
270
437
  end