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 +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +26 -0
- data/lib/better_auth/rails/active_record_adapter.rb +140 -35
- data/lib/better_auth/rails/configuration.rb +1 -0
- data/lib/better_auth/rails/controller_helpers.rb +3 -1
- data/lib/better_auth/rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5ac4180d1fa72d73ecb4012f0d8d329eb92eccc2dabebef9cd5e7e91d8e1736
|
|
4
|
+
data.tar.gz: 9c99354b9834f28e26edbfcbf257aa932ac298e64f961c812fb2d63cd998b601
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
54
|
-
return nil unless
|
|
59
|
+
existing = find_one(model: model, where: where, select: ["id"])
|
|
60
|
+
return nil unless existing
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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"] =
|
|
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
|
|
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
|
|
@@ -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:
|
|
45
|
+
context: auth_context,
|
|
44
46
|
request: request
|
|
45
47
|
)
|
|
46
48
|
BetterAuth::Session.find_current(context, disable_refresh: true)
|