better_auth-rails 0.6.2 → 0.7.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: 0e6c26cdbe7f5e5bd981dafe3b71be1c3d3b05c892aff5093854f4e352ce1a4f
4
- data.tar.gz: 62264362e911e4042dc2d460e9fbaec7b5247328d418f52dfb7b8181244b05d4
3
+ metadata.gz: a5ac4180d1fa72d73ecb4012f0d8d329eb92eccc2dabebef9cd5e7e91d8e1736
4
+ data.tar.gz: 9c99354b9834f28e26edbfcbf257aa932ac298e64f961c812fb2d63cd998b601
5
5
  SHA512:
6
- metadata.gz: 247ba4311b4eeb07dd0e7a8dc4dc7e7d9d9d3bacfbffb6e9e3e2e8abbaccf954c7d58b5381f8a70d3cf955df9428003d5dcf79234af92b2cc7864ccd668b9b18
7
- data.tar.gz: fe225c023f7f6ca485df3506106e77ff0e1ff61b7b2c162abe667e5d8c6e500230c2394111c8dcf809abbb54a30da573ca6d8691b484b878a50d7adda786d714
6
+ metadata.gz: ad8e38bc663f50f09af94f5d2a2353aed49eb61818bbd2e7b2067313ec51faa49155b24aad88885f80be8cca13840be7c4b6bf22a9a40d24cb0b7fecbf0543f4
7
+ data.tar.gz: 7455aaa5b30a2a1d8c9227e84afb53426cb3797d321803a846f39966e8f9de9e7ec691fb9abb3c18b4aabd207078ca15f99d64021251719f5d33d27aac15af74
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-05-05
11
+
12
+ ### Fixed
13
+
14
+ - Aligned Active Record adapter filtering, joins, falsey values, and lookup semantics with core adapter behavior.
15
+ - Hardened controller helper and trusted-origin behavior and passed versioned secrets through Rails configuration.
16
+ - Added MySQL and PostgreSQL integration coverage for the adapter changes.
17
+
10
18
  ## [0.2.1] - 2026-04-29
11
19
 
12
20
  ### Fixed
data/README.md CHANGED
@@ -122,6 +122,30 @@ Rails configuration is a thin option builder for the core Rack auth object. The
122
122
 
123
123
  Rails uses `BetterAuth::Rails::ActiveRecordAdapter` by default. The adapter uses whichever database adapter the Rails app is already configured with, including PostgreSQL and MySQL. To be explicit, set `config.database_adapter = :active_record`; for custom adapters, assign `config.database` directly.
124
124
 
125
+ #### Secret rotation
126
+
127
+ Rails can pass Better Auth secret rotation entries through the same `secrets` option as core:
128
+
129
+ ```ruby
130
+ BetterAuth::Rails.configure do |config|
131
+ config.secret = Rails.application.secret_key_base
132
+ config.secrets = [
133
+ {version: 2, value: Rails.application.credentials.dig(:better_auth, :secret_v2)},
134
+ {version: 1, value: Rails.application.credentials.dig(:better_auth, :secret_v1)}
135
+ ].compact
136
+ end
137
+ ```
138
+
139
+ Core Better Auth also reads `BETTER_AUTH_SECRETS` when `secrets` is not configured directly.
140
+
141
+ #### Trusted origins
142
+
143
+ The generated initializer derives `trusted_origins` from `ENV["BETTER_AUTH_URL"]`. If that environment variable is unset, Rails passes an empty list. Browser clients should set trusted origins explicitly in deployment so origin checks and callback URLs do not depend on an empty environment value. See [`host-app-responsibilities.md`](../../.docs/features/host-app-responsibilities.md) for the boundary between Better Auth origin checks, browser CORS headers, and host-app CSRF policy.
144
+
145
+ #### Option builder keys
146
+
147
+ The Rails option builder accepts unknown keys so plugin and custom options can flow through to core Better Auth. That also means typos become option keys silently. Validate configuration names against the core docs or the Ruby option names when adding new settings.
148
+
125
149
  ### JavaScript Client
126
150
 
127
151
  Ruby Better Auth exposes the same HTTP route surface. Frontend apps should use the upstream Better Auth JavaScript client and point it at the Ruby server:
@@ -199,6 +223,8 @@ end
199
223
  - `authenticated?` - Returns true when a user is present
200
224
  - `require_authentication` - Halts with `head :unauthorized` and returns `false` when no user is present
201
225
 
226
+ `ControllerHelpers` prepare the Better Auth context for the current Rails request before reading the session. Custom Rack middleware or controller code that bypasses `BetterAuth::Router` and reads sessions directly should call `prepare_for_request!` on the auth context first.
227
+
202
228
  ## Development
203
229
 
204
230
  Full documentation is being adapted in the root [`docs/`](/Users/sebastiansala/projects/better-auth/docs/README.md) app. The Rails guide lives at `docs/content/docs/integrations/rails.mdx`; pages with a Ruby port warning still contain upstream TypeScript examples for reference.
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "json"
5
+ require "time"
6
+
3
7
  module BetterAuth
4
8
  module Rails
5
9
  class ActiveRecordAdapter < BetterAuth::Adapters::Base
10
+ include BetterAuth::Adapters::JoinSupport
11
+
6
12
  begin
7
13
  require "active_record" unless defined?(::ActiveRecord)
8
14
  rescue LoadError
@@ -45,16 +51,16 @@ module BetterAuth
45
51
  model = model.to_s
46
52
  relation = relation_for(model, where: where, sort_by: sort_by, limit: limit, offset: offset, select: select, join: join)
47
53
  records = relation.map { |record| normalize_record(model, record, join: join) }
48
- collection_join?(model, join) ? aggregate_collection_joins(records) : records
54
+ collection_join?(model, join) ? aggregate_collection_joins(model, records, join) : records
49
55
  end
50
56
 
51
57
  def update(model:, where:, update:)
52
58
  model = model.to_s
53
- record = relation_for(model, where: where).first
54
- return nil unless record
59
+ existing = find_one(model: model, where: where, select: ["id"])
60
+ return nil unless existing
55
61
 
56
- record.update!(physical_attributes(model, transform_input(model, update, "update", true)))
57
- normalize_record(model, record)
62
+ update_many(model: model, where: where, update: update)
63
+ find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
58
64
  end
59
65
 
60
66
  def update_many(model:, where:, update:, returning: false)
@@ -72,9 +78,7 @@ module BetterAuth
72
78
  end
73
79
 
74
80
  def delete(model:, where:)
75
- model = model.to_s
76
- record = relation_for(model, where: where).first
77
- record&.destroy!
81
+ delete_many(model: model, where: where)
78
82
  nil
79
83
  end
80
84
 
@@ -117,12 +121,19 @@ module BetterAuth
117
121
  end
118
122
 
119
123
  def apply_where(model, relation, where)
120
- Array(where).reduce(relation) do |scope, clause|
121
- field = storage_key(fetch_key(clause, :field))
122
- column = storage_field(model, field)
123
- operator = (fetch_key(clause, :operator) || "eq").to_s
124
- value = fetch_key(clause, :value)
125
- apply_operator(scope, column, operator, value)
124
+ clauses = Array(where)
125
+ return relation if clauses.empty?
126
+
127
+ if model_class(model).respond_to?(:arel_table)
128
+ expression = clauses.each_with_index.reduce(nil) do |combined, (clause, index)|
129
+ current = where_expression(model, clause)
130
+ next current if index.zero?
131
+
132
+ (fetch_key(clause, :connector).to_s.upcase == "OR") ? combined.or(current) : combined.and(current)
133
+ end
134
+ relation.where(expression)
135
+ else
136
+ apply_where_with_relation_or(model, relation, clauses)
126
137
  end
127
138
  end
128
139
 
@@ -135,13 +146,52 @@ module BetterAuth
135
146
  when "gte" then scope.where("#{column} >= ?", value)
136
147
  when "lt" then scope.where("#{column} < ?", value)
137
148
  when "lte" then scope.where("#{column} <= ?", value)
138
- when "contains" then scope.where("#{column} LIKE ?", "%#{value}%")
139
- when "starts_with" then scope.where("#{column} LIKE ?", "#{value}%")
140
- when "ends_with" then scope.where("#{column} LIKE ?", "%#{value}")
149
+ when "contains" then scope.where("#{column} LIKE ? ESCAPE ?", "%#{escape_like(value)}%", "\\")
150
+ when "starts_with" then scope.where("#{column} LIKE ? ESCAPE ?", "#{escape_like(value)}%", "\\")
151
+ when "ends_with" then scope.where("#{column} LIKE ? ESCAPE ?", "%#{escape_like(value)}", "\\")
141
152
  else scope.where(column => value)
142
153
  end
143
154
  end
144
155
 
156
+ def apply_where_with_relation_or(model, relation, clauses)
157
+ clauses.each_with_index.reduce(nil) do |combined, (clause, index)|
158
+ field = storage_key(fetch_key(clause, :field))
159
+ column = storage_field(model, field)
160
+ operator = (fetch_key(clause, :operator) || "eq").to_s
161
+ value = fetch_key(clause, :value)
162
+ current = apply_operator(relation, column, operator, value)
163
+ next current if index.zero?
164
+
165
+ (fetch_key(clause, :connector).to_s.upcase == "OR") ? combined.or(current) : apply_operator(combined, column, operator, value)
166
+ end
167
+ end
168
+
169
+ def where_expression(model, clause)
170
+ field = storage_key(fetch_key(clause, :field))
171
+ attributes = schema_for(model).fetch(:fields).fetch(field)
172
+ column = model_class(model).arel_table[storage_field(model, field)]
173
+ operator = (fetch_key(clause, :operator) || "eq").to_s
174
+ value = fetch_key(clause, :value)
175
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
176
+ insensitive = mode == "insensitive" && attributes[:type] == "string" && !value.nil?
177
+ predicate_column = insensitive ? lower(column) : column
178
+ predicate_value = insensitive ? lower_value(value) : value
179
+
180
+ case operator
181
+ when "in" then predicate_column.in(Array(predicate_value))
182
+ when "not_in" then predicate_column.not_in(Array(predicate_value))
183
+ when "ne" then predicate_column.not_eq(predicate_value)
184
+ when "gt" then column.gt(value)
185
+ when "gte" then column.gteq(value)
186
+ when "lt" then column.lt(value)
187
+ when "lte" then column.lteq(value)
188
+ when "contains" then predicate_column.matches("%#{escape_like(predicate_value)}%", "\\")
189
+ when "starts_with" then predicate_column.matches("#{escape_like(predicate_value)}%", "\\")
190
+ when "ends_with" then predicate_column.matches("%#{escape_like(predicate_value)}", "\\")
191
+ else predicate_column.eq(predicate_value)
192
+ end
193
+ end
194
+
145
195
  def apply_select(model, relation, select)
146
196
  columns = Array(select).map { |field| storage_field(model, storage_key(field)) }
147
197
  relation.select(*columns)
@@ -162,6 +212,10 @@ module BetterAuth
162
212
 
163
213
  value_provided = input.key?(field)
164
214
  value = input[field]
215
+ if value_provided && attributes[:input] == false && value && !force_allow_id
216
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
217
+ end
218
+
165
219
  if !value_provided && action == "create" && attributes.key?(:default_value)
166
220
  value = resolve_default(attributes[:default_value])
167
221
  value_provided = true
@@ -169,9 +223,12 @@ module BetterAuth
169
223
  value = resolve_default(attributes[:on_update])
170
224
  value_provided = true
171
225
  end
172
- output[field] = value if value_provided
226
+ if !value_provided && action == "create" && attributes[:required]
227
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
228
+ end
229
+ output[field] = coerce_value(value, attributes) if value_provided
173
230
  end
174
- output["id"] = SecureRandom.urlsafe_base64(16) if action == "create" && !output.key?("id")
231
+ output["id"] = generated_id if action == "create" && !output.key?("id")
175
232
  output
176
233
  end
177
234
 
@@ -187,7 +244,7 @@ module BetterAuth
187
244
  attributes = record.respond_to?(:attributes) ? record.attributes : record
188
245
  normalized = schema_for(model).fetch(:fields).each_with_object({}) do |(field, config), output|
189
246
  column = config[:field_name] || physical_name(field)
190
- output[field] = attributes[column] if attributes.key?(column)
247
+ output[field] = coerce_output_value(attributes[column], config) if attributes.key?(column)
191
248
  end
192
249
  attach_joins(model, normalized, record, join)
193
250
  end
@@ -289,7 +346,9 @@ module BetterAuth
289
346
  from: reference.fetch(:field).to_s,
290
347
  to: foreign_key,
291
348
  collection: !unique,
292
- owner: :base
349
+ owner: :base,
350
+ relation: unique ? "one-to-one" : "one-to-many",
351
+ unique: unique
293
352
  }
294
353
  end
295
354
 
@@ -306,18 +365,12 @@ module BetterAuth
306
365
  from: foreign_key,
307
366
  to: reference.fetch(:field).to_s,
308
367
  collection: false,
309
- owner: :join
368
+ owner: :join,
369
+ relation: "one-to-one",
370
+ unique: true
310
371
  }
311
372
  end
312
373
 
313
- def reference_model_matches?(attributes, model)
314
- reference = attributes[:references]
315
- return false unless reference
316
-
317
- reference_model = reference[:model] || reference["model"]
318
- reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
319
- end
320
-
321
374
  def association_defined?(klass, association)
322
375
  klass.respond_to?(:reflect_on_association) && klass.reflect_on_association(association)
323
376
  end
@@ -334,11 +387,7 @@ module BetterAuth
334
387
  physical_name(model).split("_").map(&:capitalize).join
335
388
  end
336
389
 
337
- def collection_join?(model, join)
338
- model == "user" && join&.keys&.any? { |join_model| join_model.to_s == "account" }
339
- end
340
-
341
- def aggregate_collection_joins(records)
390
+ def aggregate_collection_joins(_model, records, _join)
342
391
  records
343
392
  end
344
393
 
@@ -378,6 +427,62 @@ module BetterAuth
378
427
  def resolve_default(value)
379
428
  value.respond_to?(:call) ? value.call : value
380
429
  end
430
+
431
+ def coerce_value(value, attributes)
432
+ return value if value.nil?
433
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
434
+
435
+ value
436
+ end
437
+
438
+ def coerce_output_value(value, attributes)
439
+ return value if value.nil?
440
+ return coerce_boolean(value) if attributes[:type] == "boolean"
441
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
442
+ return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
443
+
444
+ value
445
+ end
446
+
447
+ def coerce_boolean(value)
448
+ return value if value == true || value == false
449
+ return false if value == 0 || value.to_s == "0" || value.to_s.downcase == "f" || value.to_s.downcase == "false"
450
+ return true if value == 1 || value.to_s == "1" || value.to_s.downcase == "t" || value.to_s.downcase == "true"
451
+
452
+ value
453
+ end
454
+
455
+ def json_like?(attributes)
456
+ %w[json string[] number[]].include?(attributes[:type])
457
+ end
458
+
459
+ def parse_json_value(value)
460
+ JSON.parse(value)
461
+ rescue JSON::ParserError
462
+ value
463
+ end
464
+
465
+ def lower(node)
466
+ Arel::Nodes::NamedFunction.new("LOWER", [node])
467
+ end
468
+
469
+ def lower_value(value)
470
+ return value.map { |entry| entry.nil? ? entry : entry.to_s.downcase } if value.is_a?(Array)
471
+
472
+ value.to_s.downcase
473
+ end
474
+
475
+ def generated_id
476
+ generator = options.advanced.dig(:database, :generate_id) || options.advanced.dig(:database, :generateId)
477
+ return generator.call.to_s if generator.respond_to?(:call)
478
+ return SecureRandom.uuid if generator.to_s == "uuid"
479
+
480
+ SecureRandom.hex(16)
481
+ end
482
+
483
+ def escape_like(value)
484
+ value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
485
+ end
381
486
  end
382
487
  end
383
488
  end
@@ -8,6 +8,7 @@ module BetterAuth
8
8
  base_url
9
9
  base_path
10
10
  secret
11
+ secrets
11
12
  database
12
13
  plugins
13
14
  trusted_origins
@@ -33,6 +33,8 @@ module BetterAuth
33
33
  end
34
34
 
35
35
  def resolve_better_auth_session
36
+ auth_context = BetterAuth::Rails.auth.context
37
+ auth_context.prepare_for_request!(request) if auth_context.respond_to?(:prepare_for_request!)
36
38
  context = BetterAuth::Endpoint::Context.new(
37
39
  path: request.path,
38
40
  method: request.request_method,
@@ -40,7 +42,7 @@ module BetterAuth
40
42
  body: {},
41
43
  params: {},
42
44
  headers: {"cookie" => request.get_header("HTTP_COOKIE")},
43
- context: BetterAuth::Rails.auth.context,
45
+ context: auth_context,
44
46
  request: request
45
47
  )
46
48
  BetterAuth::Session.find_current(context, disable_refresh: true)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Rails
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala