better_auth 0.8.0 → 0.10.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +4 -4
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/sql.rb +139 -57
  6. data/lib/better_auth/configuration.rb +7 -1
  7. data/lib/better_auth/cookies.rb +11 -3
  8. data/lib/better_auth/doctor.rb +97 -0
  9. data/lib/better_auth/endpoint.rb +88 -5
  10. data/lib/better_auth/http_client.rb +46 -0
  11. data/lib/better_auth/migration_plan.rb +15 -0
  12. data/lib/better_auth/oauth2.rb +1 -1
  13. data/lib/better_auth/plugins/admin.rb +6 -1
  14. data/lib/better_auth/plugins/anonymous.rb +2 -0
  15. data/lib/better_auth/plugins/captcha.rb +1 -1
  16. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  17. data/lib/better_auth/plugins/dub.rb +8 -0
  18. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  19. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  20. data/lib/better_auth/plugins/jwt.rb +10 -3
  21. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  22. data/lib/better_auth/plugins/mcp.rb +41 -0
  23. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  24. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  25. data/lib/better_auth/plugins/one_tap.rb +17 -5
  26. data/lib/better_auth/plugins/open_api.rb +42 -2
  27. data/lib/better_auth/plugins/organization.rb +122 -11
  28. data/lib/better_auth/plugins/phone_number.rb +1 -1
  29. data/lib/better_auth/plugins/two_factor.rb +21 -0
  30. data/lib/better_auth/rate_limiter.rb +7 -2
  31. data/lib/better_auth/routes/account.rb +4 -0
  32. data/lib/better_auth/routes/email_verification.rb +5 -1
  33. data/lib/better_auth/routes/password.rb +1 -0
  34. data/lib/better_auth/routes/social.rb +29 -1
  35. data/lib/better_auth/routes/user.rb +6 -2
  36. data/lib/better_auth/schema/sql.rb +104 -15
  37. data/lib/better_auth/schema.rb +35 -2
  38. data/lib/better_auth/session.rb +2 -1
  39. data/lib/better_auth/social_providers/base.rb +4 -9
  40. data/lib/better_auth/social_providers/facebook.rb +1 -1
  41. data/lib/better_auth/social_providers/github.rb +2 -0
  42. data/lib/better_auth/social_providers/line.rb +1 -1
  43. data/lib/better_auth/social_providers/paypal.rb +1 -1
  44. data/lib/better_auth/sql_migration.rb +566 -0
  45. data/lib/better_auth/version.rb +1 -1
  46. data/lib/better_auth.rb +3 -0
  47. metadata +10 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98014e245459f3b5a7de74fbd1e41ac5449ff223e20e5f9c8ef9500e15c55d9f
4
- data.tar.gz: 28252ead0ea2f370233bcc73bcc41568604608ff2d4d891a66abf71f49e01497
3
+ metadata.gz: c5b3eeb719cd263152d0e166d19735bccbe118bd72a26093b57686105c456815
4
+ data.tar.gz: 6c7f3fc3653cba526e5f889bb2b1095fefcbcf3bb9b90c1ac50f042635e56071
5
5
  SHA512:
6
- metadata.gz: 22e4e6488cebfdc63f77d68747df4283ccf27c82381ab6bb7e372485f0c2d93cb4e37dce4748e75020f6556ff08a675ba42d47cf39a57f90c86f2d4a396c09b4
7
- data.tar.gz: a05af007c3dcd034bc035a37e825991ce76ff8af9199ae33f4a3ecf2250718ac848ae52baa84d2d60c7b5eace5d7f2750de2d40dde779b182640184b50d1a765
6
+ metadata.gz: 90a88bf3352d716dc78074b0d61de38d7188c7f6f98b5d930cd426e020ab51f5b5f21fe8d5765ad96adfbb955345113f23396cfdd65317e6b063613ed7a4da70
7
+ data.tar.gz: 28acb5fd682eff9fff9ff99cf7ac3e5c26b0dce26bb6c37c7a2f4ed6a75b2e9529626a280a244b6755e1edb40631c0057759c0b9e738f0d24c3d4a0d5f9baec4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-05-21
11
+
12
+ ### Fixed
13
+
14
+ - Fixed organization owner counting to page through adapter results instead of
15
+ relying on a single uncapped `find_many` call.
16
+ - Improved SQL, memory, cookie, rate-limit, plugin schema, social login, and
17
+ auth response edge cases for more consistent behavior across adapters.
18
+
10
19
  ## [0.7.0] - 2026-05-05
11
20
 
12
21
  ### Added
data/README.md CHANGED
@@ -13,11 +13,11 @@
13
13
  ·
14
14
  <a href="https://better-auth.com">Website</a>
15
15
  ·
16
- <a href="https://github.com/sebasxsala/better-auth/issues">Issues</a>
16
+ <a href="https://github.com/sebasxsala/better-auth-rb/issues">Issues</a>
17
17
  </p>
18
18
 
19
19
  [![Gem](https://img.shields.io/gem/v/better_auth?style=flat&colorA=000000&colorB=000000)](https://rubygems.org/gems/better_auth)
20
- [![GitHub stars](https://img.shields.io/github/stars/sebasxsala/better-auth?style=flat&colorA=000000&colorB=000000)](https://github.com/sebasxsala/better-auth/stargazers)
20
+ [![GitHub stars](https://img.shields.io/github/stars/sebasxsala/better-auth?style=flat&colorA=000000&colorB=000000)](https://github.com/sebasxsala/better-auth-rb/stargazers)
21
21
  </p>
22
22
 
23
23
  ## About the Project
@@ -212,7 +212,7 @@ Full documentation is being adapted in the root [`docs/`](/Users/sebastiansala/p
212
212
 
213
213
  ```bash
214
214
  # 1. Clone the repository
215
- git clone https://github.com/sebasxsala/better-auth.git
215
+ git clone https://github.com/sebasxsala/better-auth-rb.git
216
216
  cd better-auth/packages/better_auth
217
217
 
218
218
  # 2. Install dependencies
@@ -370,7 +370,7 @@ test/ # Core tests (Minitest)
370
370
 
371
371
  ## Contributing
372
372
 
373
- Bug reports and pull requests are welcome on GitHub at https://github.com/sebasxsala/better-auth. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/sebasxsala/better-auth/blob/main/CODE_OF_CONDUCT.md).
373
+ Bug reports and pull requests are welcome on GitHub at https://github.com/sebasxsala/better-auth-rb. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/sebasxsala/better-auth-rb/blob/main/CODE_OF_CONDUCT.md).
374
374
 
375
375
  ## License
376
376
 
@@ -6,6 +6,8 @@ require "time"
6
6
  module BetterAuth
7
7
  module Adapters
8
8
  class Memory < Base
9
+ include JoinSupport
10
+
9
11
  attr_reader :db
10
12
 
11
13
  def initialize(options, db = nil)
@@ -35,17 +37,23 @@ module BetterAuth
35
37
  end
36
38
 
37
39
  def update(model:, where:, update:)
40
+ model = model.to_s
41
+ ensure_update_input_has_fields!(model, update)
38
42
  records = table_for(model).select { |record| matches_where?(record, where || []) }
39
- data = transform_input(model.to_s, update, "update", true)
43
+ data = transform_input(model, update, "update", true)
44
+ ensure_update_data!(data)
40
45
  records.each { |record| record.merge!(data) }
41
46
  records.first
42
47
  end
43
48
 
44
49
  def update_many(model:, where:, update:)
50
+ model = model.to_s
51
+ ensure_update_input_has_fields!(model, update)
45
52
  records = table_for(model).select { |record| matches_where?(record, where || []) }
46
- data = transform_input(model.to_s, update, "update", true)
53
+ data = transform_input(model, update, "update", true)
54
+ ensure_update_data!(data)
47
55
  records.each { |record| record.merge!(data) }
48
- records.first
56
+ records.length
49
57
  end
50
58
 
51
59
  def delete(model:, where:)
@@ -109,16 +117,34 @@ module BetterAuth
109
117
  raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
110
118
  end
111
119
 
112
- output[field] = coerce_value(value, attributes) if value_provided
120
+ output[field] = coerce_value(value, field, attributes) if value_provided
113
121
  end
114
122
 
115
- output["id"] = generated_id if action == "create" && !output.key?("id")
123
+ output["id"] = generated_id(model) if action == "create" && fields.key?("id") && (!output.key?("id") || output["id"].nil?)
116
124
  output
117
125
  end
118
126
 
119
- def generated_id
127
+ def ensure_update_data!(data)
128
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if data.empty?
129
+ end
130
+
131
+ def ensure_update_input_has_fields!(model, update)
132
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless update.is_a?(Hash)
133
+
134
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
135
+ input = stringify_keys(update)
136
+ has_updatable_field = input.any? do |field, _value|
137
+ next false if field == "id" || field == "_id"
138
+
139
+ fields.key?(field) || fields.any? { |logical_field, attributes| Schema.storage_key(attributes[:field_name] || logical_field) == field }
140
+ end
141
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
142
+ end
143
+
144
+ def generated_id(model)
120
145
  generator = options.advanced.dig(:database, :generate_id)
121
146
  return generator.call.to_s if generator.respond_to?(:call)
147
+ return table_for(model).length + 1 if generator == "serial"
122
148
  return SecureRandom.uuid if generator == "uuid"
123
149
 
124
150
  SecureRandom.hex(16)
@@ -128,8 +154,9 @@ module BetterAuth
128
154
  default.respond_to?(:call) ? default.call : default
129
155
  end
130
156
 
131
- def coerce_value(value, attributes)
157
+ def coerce_value(value, field, attributes)
132
158
  return value if value.nil?
159
+ return coerce_number(value) if serial_id_field?(field, attributes)
133
160
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
134
161
 
135
162
  value
@@ -155,8 +182,10 @@ module BetterAuth
155
182
  field = Schema.storage_key(fetch_key(clause, :field))
156
183
  value = fetch_key(clause, :value)
157
184
  operator = (fetch_key(clause, :operator) || "eq").to_s
185
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
158
186
  current = record[field]
159
187
  comparable = coerce_where_value(record, field, value, operator)
188
+ current, comparable = insensitive_values(current, comparable) if insensitive_comparison?(mode, current, comparable)
160
189
 
161
190
  case operator
162
191
  when "in"
@@ -187,9 +216,9 @@ module BetterAuth
187
216
  def coerce_where_value(record, field, value, operator)
188
217
  attributes = schema_for_record_field(record, field)
189
218
  return value unless attributes
190
- return Array(value).map { |entry| coerce_scalar_where_value(entry, attributes) } if %w[in not_in].include?(operator)
219
+ return Array(value).map { |entry| coerce_scalar_where_value(entry, field, attributes) } if %w[in not_in].include?(operator)
191
220
 
192
- coerce_scalar_where_value(value, attributes)
221
+ coerce_scalar_where_value(value, field, attributes)
193
222
  end
194
223
 
195
224
  def schema_for_record_field(record, field)
@@ -201,8 +230,9 @@ module BetterAuth
201
230
  nil
202
231
  end
203
232
 
204
- def coerce_scalar_where_value(value, attributes)
233
+ def coerce_scalar_where_value(value, field, attributes)
205
234
  return value if value.nil?
235
+ return coerce_number(value) if serial_id_field?(field, attributes)
206
236
 
207
237
  case attributes[:type]
208
238
  when "boolean"
@@ -221,6 +251,32 @@ module BetterAuth
221
251
  value
222
252
  end
223
253
 
254
+ def serial_id_field?(field, attributes)
255
+ return false unless options.advanced.dig(:database, :generate_id) == "serial"
256
+ return true if field.to_s == "id"
257
+
258
+ reference = attributes[:references]
259
+ return false unless reference
260
+
261
+ (reference[:field] || reference["field"]).to_s == "id"
262
+ end
263
+
264
+ def insensitive_comparison?(mode, current, comparable)
265
+ return false unless mode == "insensitive"
266
+
267
+ current.is_a?(String) && (comparable.is_a?(String) || comparable.is_a?(Array))
268
+ end
269
+
270
+ def insensitive_values(current, comparable)
271
+ normalized_current = current.downcase
272
+ normalized_comparable = if comparable.is_a?(Array)
273
+ comparable.map { |entry| entry.is_a?(String) ? entry.downcase : entry }
274
+ else
275
+ comparable.downcase
276
+ end
277
+ [normalized_current, normalized_comparable]
278
+ end
279
+
224
280
  def coerce_number(value)
225
281
  return value unless value.is_a?(String)
226
282
  return value.to_i if /\A-?\d+\z/.match?(value)
@@ -252,18 +308,76 @@ module BetterAuth
252
308
 
253
309
  def apply_join(model, record, join)
254
310
  joined = record.dup
255
- join.each_key do |join_model|
256
- join_model = join_model.to_s
257
- joined[join_model] = case [model, join_model]
258
- when ["session", "user"], ["account", "user"]
259
- table_for("user").find { |user| user["id"] == record["userId"] }
260
- when ["user", "account"]
261
- table_for("account").select { |account| account["userId"] == record["id"] }
311
+ normalized_join(model, join).each do |join_model, config|
312
+ matches = table_for(join_model).select do |join_record|
313
+ join_record[config.fetch(:to)] == record[config.fetch(:from)]
314
+ end.map(&:dup)
315
+
316
+ joined[join_model] = if one_to_one_join?(config)
317
+ matches.first
318
+ else
319
+ matches.first(join_limit(config))
262
320
  end
263
321
  end
264
322
  joined
265
323
  end
266
324
 
325
+ def one_to_one_join?(config)
326
+ config[:relation] == "one-to-one" || config[:unique] == true
327
+ end
328
+
329
+ def join_limit(config)
330
+ value = config[:limit]
331
+ return 100 if value.nil?
332
+
333
+ parsed = Integer(value)
334
+ parsed.positive? ? parsed : 100
335
+ rescue ArgumentError, TypeError
336
+ 100
337
+ end
338
+
339
+ def inferred_join_config(model, join_model)
340
+ foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
341
+ reference_model_matches?(attributes, model)
342
+ end
343
+ forward_join = true
344
+
345
+ if foreign_keys.empty?
346
+ foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
347
+ reference_model_matches?(attributes, join_model)
348
+ end
349
+ forward_join = false
350
+ end
351
+
352
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
353
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported." if foreign_keys.length > 1
354
+
355
+ foreign_key, attributes = foreign_keys.first
356
+ reference = attributes.fetch(:references)
357
+ if forward_join
358
+ unique = attributes[:unique] == true
359
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
360
+ else
361
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
362
+ end
363
+ end
364
+
365
+ def schema_for(model)
366
+ Schema.auth_tables(options).fetch(model.to_s)
367
+ end
368
+
369
+ def reference_model_matches?(attributes, model)
370
+ reference = attributes[:references]
371
+ return false unless reference
372
+
373
+ reference_model = reference[:model] || reference["model"]
374
+ reference_model.to_s == model.to_s || reference_model.to_s == schema_for(model).fetch(:model_name)
375
+ end
376
+
377
+ def storage_key(field)
378
+ Schema.storage_key(field)
379
+ end
380
+
267
381
  def stringify_keys(data)
268
382
  data.each_with_object({}) do |(key, value), result|
269
383
  result[Schema.storage_key(key)] = value
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "json"
5
+ require "monitor"
5
6
  require "time"
6
7
 
7
8
  module BetterAuth
@@ -15,6 +16,7 @@ module BetterAuth
15
16
  super(options)
16
17
  @connection = connection
17
18
  @dialect = dialect.to_sym
19
+ @connection_lock = Monitor.new
18
20
  end
19
21
 
20
22
  def create(model:, data:, force_allow_id: false)
@@ -30,7 +32,8 @@ module BetterAuth
30
32
  row = rows.first
31
33
  return normalize_record(model, row) if row
32
34
 
33
- find_one(model: model, where: [{field: "id", value: input.fetch("id")}])
35
+ lookup = create_lookup(model, input)
36
+ lookup ? find_one(model: model, where: [lookup]) : input
34
37
  end
35
38
 
36
39
  def find_one(model:, where: [], select: nil, join: nil)
@@ -61,21 +64,25 @@ module BetterAuth
61
64
 
62
65
  def update(model:, where:, update:)
63
66
  model = model.to_s
67
+ ensure_update_input_has_fields!(model, update)
64
68
  if dialect == :postgres
65
69
  records = update_many(model: model, where: where, update: update, returning: true)
66
70
  return records.is_a?(Array) ? records.first : records
67
71
  end
68
72
 
69
- existing = find_one(model: model, where: where, select: ["id"])
73
+ existing = find_one(model: model, where: where)
70
74
  return nil unless existing
71
75
 
72
76
  update_many(model: model, where: where, update: update)
73
- find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
77
+ lookup = record_lookup(model, existing)
78
+ lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
74
79
  end
75
80
 
76
81
  def update_many(model:, where:, update:, returning: false)
77
82
  model = model.to_s
83
+ ensure_update_input_has_fields!(model, update)
78
84
  data = transform_input(model, update, "update", true)
85
+ ensure_update_data!(data)
79
86
  params = []
80
87
  assignments = data.each_key.map do |field|
81
88
  params << data[field]
@@ -87,11 +94,11 @@ module BetterAuth
87
94
  sql << " SET "
88
95
  sql << assignments.join(", ")
89
96
  sql << " WHERE #{where_sql}" unless where_sql.empty?
90
- sql << " RETURNING *" if dialect == :postgres
91
- rows = execute(sql, params).map { |row| normalize_record(model, row) }
92
- return rows if returning || dialect == :postgres
97
+ sql << " RETURNING *" if dialect == :postgres && returning
98
+ result = execute(sql, params, affected_rows_result: !returning)
99
+ return result.map { |row| normalize_record(model, row) } if returning
93
100
 
94
- nil
101
+ affected_rows(result)
95
102
  end
96
103
 
97
104
  def delete(model:, where:)
@@ -106,7 +113,7 @@ module BetterAuth
106
113
  sql = +"DELETE FROM "
107
114
  sql << quote(table_for(model))
108
115
  sql << " WHERE #{where_sql}" unless where_sql.empty?
109
- result = execute(sql, params)
116
+ result = execute(sql, params, affected_rows_result: true)
110
117
  affected_rows(result)
111
118
  end
112
119
 
@@ -122,13 +129,15 @@ module BetterAuth
122
129
  end
123
130
 
124
131
  def transaction
125
- execute("BEGIN", [])
126
- result = yield self
127
- execute("COMMIT", [])
128
- result
129
- rescue
130
- execute("ROLLBACK", [])
131
- raise
132
+ @connection_lock.synchronize do
133
+ execute("BEGIN", [])
134
+ result = yield self
135
+ execute("COMMIT", [])
136
+ result
137
+ rescue
138
+ execute("ROLLBACK", [])
139
+ raise
140
+ end
132
141
  end
133
142
 
134
143
  private
@@ -160,10 +169,47 @@ module BetterAuth
160
169
  output[field] = coerce_value(value, attributes) if value_provided
161
170
  end
162
171
 
163
- output["id"] = generated_id if action == "create" && !output.key?("id")
172
+ output["id"] = generated_id if action == "create" && !output.key?("id") && fields.key?("id")
164
173
  output
165
174
  end
166
175
 
176
+ def create_lookup(model, input)
177
+ fields = schema_for(model).fetch(:fields)
178
+ return {field: "id", value: input.fetch("id")} if fields.key?("id") && input.key?("id")
179
+
180
+ unique_field = fields.find { |field, attributes| attributes[:unique] && input.key?(field) }
181
+ return {field: unique_field.first, value: input.fetch(unique_field.first)} if unique_field
182
+
183
+ nil
184
+ end
185
+
186
+ def record_lookup(model, record)
187
+ fields = schema_for(model).fetch(:fields)
188
+ return {field: "id", value: record.fetch("id")} if fields.key?("id") && record.key?("id")
189
+
190
+ unique_field = fields.find { |field, attributes| attributes[:unique] && record.key?(field) }
191
+ return {field: unique_field.first, value: record.fetch(unique_field.first)} if unique_field
192
+
193
+ nil
194
+ end
195
+
196
+ def ensure_update_data!(data)
197
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if data.empty?
198
+ end
199
+
200
+ def ensure_update_input_has_fields!(model, update)
201
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless update.is_a?(Hash)
202
+
203
+ fields = schema_for(model).fetch(:fields)
204
+ input = stringify_keys(update)
205
+ has_updatable_field = input.any? do |field, _value|
206
+ next false if field == "id" || field == "_id"
207
+
208
+ fields.key?(field) || fields.any? { |logical_field, attributes| storage_key(attributes[:field_name] || logical_field) == field }
209
+ end
210
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
211
+ end
212
+
167
213
  def select_sql(model, select, join)
168
214
  fields = Array(select).empty? ? schema_for(model).fetch(:fields).keys : Array(select).map { |field| storage_key(field) }
169
215
  columns = fields.map do |field|
@@ -226,28 +272,35 @@ module BetterAuth
226
272
  operator = (fetch_key(clause, :operator) || "eq").to_s
227
273
  value = fetch_key(clause, :value)
228
274
  attributes = schema_for(model).fetch(:fields).fetch(field)
275
+ insensitive = insensitive_string_predicate?(clause, attributes)
276
+ predicate_column = insensitive ? "LOWER(#{column})" : column
229
277
 
230
- expression = case operator
231
- when "in", "not_in"
232
- values = Array(value).map { |entry| coerce_where_value(entry, attributes) }
233
- placeholders = values.map do |entry|
234
- params << entry
235
- placeholder(params.length)
236
- end.join(", ")
237
- sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
238
- "#{column} #{sql_operator} (#{placeholders})"
239
- when "contains", "starts_with", "ends_with"
240
- escaped = escape_like(value)
241
- pattern = case operator
242
- when "starts_with" then "#{escaped}%"
243
- when "ends_with" then "%#{escaped}"
244
- else "%#{escaped}%"
245
- end
246
- params << pattern
247
- "#{column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
278
+ expression = if value.nil? && %w[eq ne].include?(operator)
279
+ null_operator = (operator == "ne") ? "IS NOT NULL" : "IS NULL"
280
+ "#{column} #{null_operator}"
248
281
  else
249
- params << coerce_where_value(value, attributes)
250
- "#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
282
+ case operator
283
+ when "in", "not_in"
284
+ values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
285
+ placeholders = values.map do |entry|
286
+ params << entry
287
+ placeholder(params.length)
288
+ end.join(", ")
289
+ sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
290
+ "#{predicate_column} #{sql_operator} (#{placeholders})"
291
+ when "contains", "starts_with", "ends_with"
292
+ escaped = escape_like(insensitive ? value.to_s.downcase : value)
293
+ pattern = case operator
294
+ when "starts_with" then "#{escaped}%"
295
+ when "ends_with" then "%#{escaped}"
296
+ else "%#{escaped}%"
297
+ end
298
+ params << pattern
299
+ "#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
300
+ else
301
+ params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
302
+ "#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
303
+ end
251
304
  end
252
305
 
253
306
  connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
@@ -286,32 +339,59 @@ module BetterAuth
286
339
  }.fetch(operator, "=")
287
340
  end
288
341
 
289
- def execute(sql, params)
290
- if connection.respond_to?(:exec_params)
291
- result = connection.exec_params(sql, params)
292
- return result.to_a if result.respond_to?(:to_a)
293
-
294
- result
295
- elsif connection.respond_to?(:query) && params.empty?
296
- result = connection.query(sql)
297
- result.respond_to?(:to_a) ? result.to_a : result
298
- elsif connection.respond_to?(:prepare)
299
- statement = connection.prepare(sql)
300
- result = statement.execute(*params)
301
- result.respond_to?(:to_a) ? result.to_a : result
302
- elsif connection.respond_to?(:execute)
303
- result = connection.execute(sql, params)
304
- result.respond_to?(:to_a) ? result.to_a : result
305
- else
306
- raise Error, "SQL connection must respond to exec_params or prepare"
342
+ def insensitive_string_predicate?(clause, attributes)
343
+ fetch_key(clause, :mode).to_s == "insensitive" && attributes[:type] == "string"
344
+ end
345
+
346
+ def execute(sql, params, affected_rows_result: false)
347
+ @connection_lock.synchronize do
348
+ if connection.respond_to?(:exec_params)
349
+ result = connection.exec_params(sql, params)
350
+ return affected_rows_result ? result : [] if result.respond_to?(:fields) && result.fields.empty?
351
+ return result.to_a if result.respond_to?(:to_a)
352
+
353
+ result
354
+ elsif connection.respond_to?(:query) && params.empty?
355
+ result = connection.query(sql)
356
+ result.respond_to?(:to_a) ? result.to_a : result
357
+ elsif dialect == :sqlite && connection.respond_to?(:execute)
358
+ result = connection.execute(sql, params)
359
+ result.respond_to?(:to_a) ? result.to_a : result
360
+ elsif connection.respond_to?(:prepare)
361
+ statement = connection.prepare(sql)
362
+ result = nil
363
+ begin
364
+ result = statement.execute(*params)
365
+ if result.nil?
366
+ return [] unless affected_rows_result
367
+ return statement.affected_rows if statement.respond_to?(:affected_rows)
368
+ return connection.affected_rows if connection.respond_to?(:affected_rows)
369
+ end
370
+
371
+ rows = result.respond_to?(:to_a) ? result.to_a : result
372
+ rows
373
+ ensure
374
+ if result.respond_to?(:close)
375
+ result.close
376
+ elsif statement.respond_to?(:close)
377
+ statement.close
378
+ end
379
+ end
380
+ elsif connection.respond_to?(:execute)
381
+ result = connection.execute(sql, params)
382
+ result.respond_to?(:to_a) ? result.to_a : result
383
+ else
384
+ raise Error, "SQL connection must respond to exec_params or prepare"
385
+ end
307
386
  end
308
387
  end
309
388
 
310
389
  def affected_rows(result)
311
390
  return result.cmd_tuples if result.respond_to?(:cmd_tuples)
312
391
  return result.affected_rows if result.respond_to?(:affected_rows)
313
- return connection.changes if connection.respond_to?(:changes)
314
392
  return result.to_i if result.respond_to?(:to_i)
393
+ return connection.affected_rows if connection.respond_to?(:affected_rows)
394
+ return connection.changes if connection.respond_to?(:changes)
315
395
 
316
396
  0
317
397
  end
@@ -406,11 +486,11 @@ module BetterAuth
406
486
  end
407
487
 
408
488
  def escape_like(value)
409
- value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
489
+ value.to_s.gsub(/[!%_]/) { |match| "!#{match}" }
410
490
  end
411
491
 
412
492
  def escape_literal
413
- (dialect == :postgres) ? "'\\\\'" : "'\\'"
493
+ "'!'"
414
494
  end
415
495
 
416
496
  def resolve_default(default)
@@ -423,6 +503,7 @@ module BetterAuth
423
503
  return value.iso8601(6) if dialect == :sqlite && attributes[:type] == "date" && value.respond_to?(:iso8601)
424
504
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
425
505
  return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
506
+ return value.encode(Encoding::UTF_8) if attributes[:type] == "string" && value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT
426
507
 
427
508
  value
428
509
  end
@@ -446,6 +527,7 @@ module BetterAuth
446
527
  def coerce_output_value(value, attributes)
447
528
  return value if value.nil?
448
529
  return coerce_boolean(value) if attributes[:type] == "boolean"
530
+ return coerce_number(value) if attributes[:type] == "number"
449
531
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
450
532
  return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
451
533
 
@@ -75,7 +75,7 @@ module BetterAuth
75
75
  @hooks = options[:hooks]
76
76
  @on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
77
77
  @telemetry = symbolize_keys(options[:telemetry] || {})
78
- @social_providers = symbolize_keys(options[:social_providers] || {})
78
+ @social_providers = normalize_social_providers(options[:social_providers])
79
79
  @trusted_origins_callbacks = []
80
80
  @trusted_origins_callbacks << options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
81
81
  @trusted_origins_callback = combined_trusted_origins_callback
@@ -371,6 +371,12 @@ module BetterAuth
371
371
  Array(value).compact.reject { |plugin| plugin == false }.map { |plugin| Plugin.coerce(plugin) }
372
372
  end
373
373
 
374
+ def normalize_social_providers(value)
375
+ symbolize_keys(value || {}).reject do |_id, provider|
376
+ provider.nil? || provider == false || (provider.is_a?(Hash) && provider[:enabled] == false)
377
+ end
378
+ end
379
+
374
380
  def normalize_trusted_origins(value)
375
381
  origins = []
376
382
  origins << base_url unless base_url.nil? || base_url.empty?
@@ -67,7 +67,7 @@ module BetterAuth
67
67
  name, value = pair.split("=", 2)
68
68
  next if name.to_s.empty? || value.nil?
69
69
 
70
- result[name.strip] = value.strip
70
+ result[name.strip] = decode_cookie_value(value.strip)
71
71
  end
72
72
  end
73
73
 
@@ -150,12 +150,14 @@ module BetterAuth
150
150
  Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
151
151
  end
152
152
 
153
- def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil)
153
+ def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil, cookie_full_name: nil)
154
154
  cookie_header = header_value(request_or_cookie_header)
155
155
  return nil if cookie_header.to_s.empty?
156
156
 
157
157
  parsed = parse_cookies(cookie_header)
158
- name = if is_secure.nil?
158
+ name = if cookie_full_name
159
+ cookie_full_name
160
+ elsif is_secure.nil?
159
161
  production_environment? ? "#{SECURE_COOKIE_PREFIX}#{cookie_prefix}.#{cookie_name}" : "#{cookie_prefix}.#{cookie_name}"
160
162
  else
161
163
  secure_prefix = is_secure ? SECURE_COOKIE_PREFIX : ""
@@ -248,6 +250,12 @@ module BetterAuth
248
250
  chunks.sort_by(&:first).map(&:last).join
249
251
  end
250
252
 
253
+ def decode_cookie_value(value)
254
+ URI.decode_uri_component(value)
255
+ rescue ArgumentError
256
+ value
257
+ end
258
+
251
259
  def header_value(request_or_cookie_header)
252
260
  return request_or_cookie_header.headers["cookie"] if request_or_cookie_header.respond_to?(:headers)
253
261
  return request_or_cookie_header.get_header("HTTP_COOKIE") if request_or_cookie_header.respond_to?(:get_header)