better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -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,193 +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
346
  return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
125
347
  return {window: 60, max: 3} if path == "/request-password-reset" ||
126
- path == "/send-verification-email" ||
127
- path.start_with?("/forget-password") ||
128
- path == "/email-otp/send-verification-otp" ||
129
- path == "/email-otp/request-password-reset"
130
-
348
+ path == "/send-verification-email" || path.start_with?("/forget-password") ||
349
+ path == "/email-otp/send-verification-otp" || path == "/email-otp/request-password-reset"
131
350
  nil
132
351
  end
133
352
 
134
353
  def matching_custom_rule(config, path)
135
354
  custom_rules = config[:custom_rules] || {}
136
- custom_rules.find do |pattern, _rule|
137
- path_matches?(pattern.to_s, path)
138
- end&.last
355
+ custom_rules.find { |pattern, _rule| path_matches?(pattern.to_s, path) }&.last
139
356
  end
140
357
 
141
358
  def resolve_custom_rule(rule, request, current)
142
359
  return false if rule == false
143
360
  return rule.call(request, current) if rule.respond_to?(:call)
144
-
145
361
  rule || current
146
362
  end
147
363
 
148
364
  def storage_for(context, config)
149
365
  return [:custom, config[:custom_storage]] if config[:custom_storage]
150
366
  return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
151
-
152
367
  if config[:storage] == "secondary-storage" && context.options.secondary_storage
153
368
  return [:secondary, context.options.secondary_storage]
154
369
  end
155
-
156
370
  [:memory, @memory_store]
157
371
  end
158
372
 
159
- def read_storage((type, storage), key)
160
- return read_database_storage(storage, key) if type == :database
161
-
162
- data = storage.get(key)
163
- data = JSON.parse(data) if type == :secondary && data.is_a?(String)
164
- normalize_rate_limit_data(symbolize_keys(data))
165
- rescue JSON::ParserError
166
- nil
167
- end
168
-
169
- def write_storage((type, storage), key, data, ttl:, update:)
170
- return write_database_storage(storage, key, data) if type == :database
171
-
172
- value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
173
- return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
174
-
175
- call_storage_set(storage, key, value, ttl: ttl, update: update)
176
- end
177
-
178
- def read_database_storage(adapter, key)
179
- data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
180
- normalize_rate_limit_data(symbolize_keys(data))
181
- end
182
-
183
- def write_database_storage(adapter, key, data)
184
- value = secondary_storage_data(data)
185
- existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
186
- if existing
187
- adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
188
- else
189
- adapter.create(model: "rateLimit", data: value)
190
- end
191
- end
192
-
193
373
  def secondary_storage_data(data)
194
- {
195
- key: data[:key],
196
- count: data[:count],
197
- lastRequest: (data[:last_request].to_f * 1000).to_i
198
- }
199
- end
200
-
201
- def call_secondary_storage_set(storage, key, value, ttl:, update:)
202
- storage.set(key, value, ttl)
203
- rescue ArgumentError
204
- call_storage_set(storage, key, value, ttl: ttl, update: update)
205
- end
206
-
207
- def call_storage_set(storage, key, value, ttl:, update:)
208
- storage.set(key, value, ttl: ttl, update: update)
209
- rescue ArgumentError
210
- begin
211
- storage.set(key, value, ttl, update)
212
- rescue ArgumentError
213
- begin
214
- storage.set(key, value, ttl)
215
- rescue ArgumentError
216
- storage.set(key, value)
217
- end
218
- end
374
+ {key: data[:key], count: data[:count], lastRequest: (data[:last_request].to_f * 1000).to_i}
219
375
  end
220
376
 
221
377
  def symbolize_keys(value)
@@ -231,7 +387,6 @@ module BetterAuth
231
387
 
232
388
  last_request = data[:last_request]
233
389
  return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
234
-
235
390
  data.merge(last_request: last_request / 1000.0)
236
391
  end
237
392
 
@@ -244,32 +399,39 @@ module BetterAuth
244
399
  end
245
400
 
246
401
  def warn_missing_client_ip(context)
247
- return if @warned_missing_client_ip
248
- 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)
249
412
 
250
- @warned_missing_client_ip = true
251
- logger = context.logger
413
+ instance_variable_set(variable, true)
414
+ yield
415
+ end
416
+ end
417
+
418
+ def log(logger, level, message, *arguments)
252
419
  if logger.respond_to?(:call)
253
- logger.call(:warn, MISSING_CLIENT_IP_WARNING)
254
- elsif logger.respond_to?(:warn)
255
- 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)
256
423
  end
257
424
  end
258
425
 
259
426
  def matching_plugin_rule(context, path)
260
- context.options.plugins
261
- .flat_map { |plugin| Array(plugin[:rate_limit]) }
262
- .find do |rule|
263
- matcher = rule[:path_matcher]
264
- matcher&.call(path)
265
- end
427
+ context.options.plugins.flat_map { |plugin| Array(plugin[:rate_limit]) }
428
+ .find { |rule| rule[:path_matcher]&.call(path) }
266
429
  end
267
430
 
268
431
  def path_matches?(pattern, path)
269
432
  return path == pattern unless pattern.include?("*")
270
433
 
271
- regex = Regexp.escape(pattern).gsub("\\*", ".*")
272
- /\A#{regex}\z/.match?(path)
434
+ /\A#{Regexp.escape(pattern).gsub("\\*", ".*")}\z/.match?(path)
273
435
  end
274
436
  end
275
437
  end