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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/LICENSE.md +1 -1
- data/README.md +138 -91
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +191 -56
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +294 -49
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +41 -46
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +110 -14
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +14 -3
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +4 -4
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +79 -28
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +47 -9
- data/lib/better_auth/plugins/dub.rb +2 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +141 -146
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +37 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +61 -60
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +35 -7
- data/lib/better_auth/plugins/organization.rb +455 -122
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +24 -26
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +186 -21
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +297 -135
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +40 -8
- data/lib/better_auth/routes/email_verification.rb +88 -5
- data/lib/better_auth/routes/password.rb +5 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +169 -54
- data/lib/better_auth/routes/user.rb +11 -8
- data/lib/better_auth/schema/sql.rb +90 -24
- data/lib/better_auth/schema.rb +124 -3
- data/lib/better_auth/session.rb +27 -9
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +41 -14
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +5 -5
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +11 -41
- metadata +5 -13
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -256
- data/lib/better_auth/plugins/oidc_provider.rb +0 -769
|
@@ -36,6 +36,34 @@ module BetterAuth
|
|
|
36
36
|
lookup ? find_one(model: model, where: [lookup]) : input
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def create_if_absent(model:, data:, conflict_field: "id", force_allow_id: true)
|
|
40
|
+
model = model.to_s
|
|
41
|
+
field = atomic_schema_field(schema_for(model).fetch(:fields), conflict_field)
|
|
42
|
+
input = transform_input(model, data, "create", force_allow_id)
|
|
43
|
+
raise APIError.new("BAD_REQUEST", message: "Missing conflict field #{conflict_field}") unless input.key?(field)
|
|
44
|
+
|
|
45
|
+
columns = input.keys.map { |key| storage_field(model, key) }
|
|
46
|
+
params = input.values
|
|
47
|
+
values = params.each_index.map { |index| placeholder(index + 1) }.join(", ")
|
|
48
|
+
table = quote(table_for(model))
|
|
49
|
+
conflict_column = quote(storage_field(model, field))
|
|
50
|
+
quoted_columns = columns.map { |column| quote(column) }.join(", ")
|
|
51
|
+
|
|
52
|
+
case dialect
|
|
53
|
+
when :postgres, :sqlite
|
|
54
|
+
sql = "INSERT INTO #{table} (#{quoted_columns}) VALUES (#{values}) ON CONFLICT (#{conflict_column}) DO NOTHING RETURNING #{conflict_column}"
|
|
55
|
+
!execute(sql, params).empty?
|
|
56
|
+
when :mysql
|
|
57
|
+
transaction { create_if_absent_mysql(model, field, input, table, quoted_columns, values, params) }
|
|
58
|
+
when :mssql
|
|
59
|
+
sql = "INSERT INTO #{table} (#{quoted_columns}) OUTPUT inserted.#{conflict_column} " \
|
|
60
|
+
"SELECT #{values} WHERE NOT EXISTS (SELECT 1 FROM #{table} WITH (UPDLOCK, HOLDLOCK) WHERE #{conflict_column} = #{placeholder(params.length + 1)})"
|
|
61
|
+
!execute(sql, params + [input.fetch(field)]).empty?
|
|
62
|
+
else
|
|
63
|
+
raise NotImplementedError, "create_if_absent is unsupported for #{dialect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
39
67
|
def find_one(model:, where: [], select: nil, join: nil)
|
|
40
68
|
if collection_join?(model.to_s, join)
|
|
41
69
|
find_many(model: model, where: where, select: select, join: join).first
|
|
@@ -64,6 +92,8 @@ module BetterAuth
|
|
|
64
92
|
|
|
65
93
|
def update(model:, where:, update:)
|
|
66
94
|
model = model.to_s
|
|
95
|
+
return nil if Array(where).empty?
|
|
96
|
+
|
|
67
97
|
ensure_update_input_has_fields!(model, update)
|
|
68
98
|
if dialect == :postgres
|
|
69
99
|
records = update_many(model: model, where: where, update: update, returning: true)
|
|
@@ -73,7 +103,9 @@ module BetterAuth
|
|
|
73
103
|
existing = find_one(model: model, where: where)
|
|
74
104
|
return nil unless existing
|
|
75
105
|
|
|
76
|
-
update_many(model: model, where: where, update: update)
|
|
106
|
+
updated_count = update_many(model: model, where: where, update: update)
|
|
107
|
+
return nil unless updated_count.to_i.positive?
|
|
108
|
+
|
|
77
109
|
lookup = record_lookup(model, existing)
|
|
78
110
|
lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
|
|
79
111
|
end
|
|
@@ -106,13 +138,14 @@ module BetterAuth
|
|
|
106
138
|
nil
|
|
107
139
|
end
|
|
108
140
|
|
|
109
|
-
def delete_many(model:, where:)
|
|
141
|
+
def delete_many(model:, where:, limit: nil)
|
|
110
142
|
model = model.to_s
|
|
111
143
|
params = []
|
|
112
144
|
where_sql = build_where(model, where || [], params)
|
|
113
145
|
sql = +"DELETE FROM "
|
|
114
146
|
sql << quote(table_for(model))
|
|
115
147
|
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
148
|
+
sql << " LIMIT #{Integer(limit)}" if limit && dialect == :mysql
|
|
116
149
|
result = execute(sql, params, affected_rows_result: true)
|
|
117
150
|
affected_rows(result)
|
|
118
151
|
end
|
|
@@ -128,10 +161,37 @@ module BetterAuth
|
|
|
128
161
|
(row["count"] || row[:count] || 0).to_i
|
|
129
162
|
end
|
|
130
163
|
|
|
164
|
+
def consume_one(model:, where:)
|
|
165
|
+
model = model.to_s
|
|
166
|
+
case dialect
|
|
167
|
+
when :postgres, :sqlite
|
|
168
|
+
consume_one_with_returning(model, where)
|
|
169
|
+
when :mssql
|
|
170
|
+
consume_one_with_output(model, where)
|
|
171
|
+
else
|
|
172
|
+
transaction { consume_one_with_lock(model, where) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def increment_one(model:, where:, increment:, set: nil, allow_server_managed: false)
|
|
177
|
+
model = model.to_s
|
|
178
|
+
increments, assignments = normalize_atomic_update(model, increment, set, allow_server_managed)
|
|
179
|
+
case dialect
|
|
180
|
+
when :postgres, :sqlite
|
|
181
|
+
increment_one_with_returning(model, where, increments, assignments)
|
|
182
|
+
when :mssql
|
|
183
|
+
increment_one_with_output(model, where, increments, assignments)
|
|
184
|
+
else
|
|
185
|
+
transaction { increment_one_with_lock(model, where, increments, assignments) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
131
189
|
def transaction
|
|
190
|
+
return yield active_transaction_adapter if active_transaction_adapter
|
|
191
|
+
|
|
132
192
|
@connection_lock.synchronize do
|
|
133
193
|
execute("BEGIN", [])
|
|
134
|
-
result = yield self
|
|
194
|
+
result = with_transaction_context(self) { yield self }
|
|
135
195
|
execute("COMMIT", [])
|
|
136
196
|
result
|
|
137
197
|
rescue
|
|
@@ -142,6 +202,176 @@ module BetterAuth
|
|
|
142
202
|
|
|
143
203
|
private
|
|
144
204
|
|
|
205
|
+
def consume_one_with_returning(model, where)
|
|
206
|
+
params = []
|
|
207
|
+
where_sql = build_where(model, where || [], params)
|
|
208
|
+
lookup = quote(storage_field(model, atomic_lookup_field(model)))
|
|
209
|
+
lock = (dialect == :postgres) ? " FOR UPDATE" : ""
|
|
210
|
+
table = quote(table_for(model))
|
|
211
|
+
sql = "DELETE FROM #{table} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
|
|
212
|
+
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
213
|
+
sql << " LIMIT 1#{lock}) RETURNING *"
|
|
214
|
+
normalize_record(model, execute(sql, params).first)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def consume_one_with_output(model, where)
|
|
218
|
+
params = []
|
|
219
|
+
where_sql = build_where(model, where || [], params)
|
|
220
|
+
sql = "DELETE TOP (1) FROM #{quote(table_for(model))} OUTPUT deleted.*"
|
|
221
|
+
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
222
|
+
normalize_record(model, execute(sql, params).first)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def consume_one_with_lock(model, where)
|
|
226
|
+
target = select_atomic_target(model, where)
|
|
227
|
+
return nil unless target
|
|
228
|
+
|
|
229
|
+
lookup = record_lookup(model, target)
|
|
230
|
+
raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
|
|
231
|
+
|
|
232
|
+
deleted = delete_many(model: model, where: [lookup], limit: 1)
|
|
233
|
+
ensure_numeric_affected_rows!(deleted, "delete_many")
|
|
234
|
+
deleted.positive? ? target : nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def increment_one_with_returning(model, where, increments, assignments)
|
|
238
|
+
params = []
|
|
239
|
+
assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
|
|
240
|
+
where_sql = build_where(model, where || [], params)
|
|
241
|
+
lookup = quote(storage_field(model, atomic_lookup_field(model)))
|
|
242
|
+
lock = (dialect == :postgres) ? " FOR UPDATE" : ""
|
|
243
|
+
table = quote(table_for(model))
|
|
244
|
+
sql = "UPDATE #{table} SET #{assignment_sql} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
|
|
245
|
+
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
246
|
+
sql << " LIMIT 1#{lock}) RETURNING *"
|
|
247
|
+
normalize_record(model, execute(sql, params).first)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def increment_one_with_output(model, where, increments, assignments)
|
|
251
|
+
params = []
|
|
252
|
+
assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
|
|
253
|
+
where_sql = build_where(model, where || [], params)
|
|
254
|
+
sql = "UPDATE TOP (1) #{quote(table_for(model))} SET #{assignment_sql} OUTPUT inserted.*"
|
|
255
|
+
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
256
|
+
normalize_record(model, execute(sql, params).first)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def increment_one_with_lock(model, where, increments, assignments)
|
|
260
|
+
target = select_atomic_target(model, where)
|
|
261
|
+
return nil unless target
|
|
262
|
+
|
|
263
|
+
lookup = record_lookup(model, target)
|
|
264
|
+
raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
|
|
265
|
+
|
|
266
|
+
params = []
|
|
267
|
+
assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
|
|
268
|
+
guarded_where = Array(where) + [lookup.merge(connector: "AND")]
|
|
269
|
+
where_sql = build_where(model, guarded_where, params)
|
|
270
|
+
sql = "UPDATE #{quote(table_for(model))} SET #{assignment_sql} WHERE #{where_sql} LIMIT 1"
|
|
271
|
+
affected = affected_rows(execute(sql, params, affected_rows_result: true))
|
|
272
|
+
ensure_numeric_affected_rows!(affected, "update")
|
|
273
|
+
find_one(model: model, where: [lookup])
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def select_atomic_target(model, where)
|
|
277
|
+
params = []
|
|
278
|
+
where_sql = build_where(model, where || [], params)
|
|
279
|
+
sql = "SELECT #{select_sql(model, nil, nil)} FROM #{quote(table_for(model))}"
|
|
280
|
+
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
281
|
+
sql << " LIMIT 1 FOR UPDATE"
|
|
282
|
+
normalize_record(model, execute(sql, params).first)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def normalize_atomic_update(model, increment, set, allow_server_managed)
|
|
286
|
+
raise APIError.new("BAD_REQUEST", message: "increment must be a Hash") unless increment.is_a?(Hash)
|
|
287
|
+
|
|
288
|
+
fields = schema_for(model).fetch(:fields)
|
|
289
|
+
increments = increment.each_with_object({}) do |(field, delta), result|
|
|
290
|
+
logical_field = atomic_schema_field(fields, field)
|
|
291
|
+
attributes = fields[logical_field]
|
|
292
|
+
valid_field = attributes && logical_field != "id" && attributes[:type] == "number"
|
|
293
|
+
valid_field = false if attributes && attributes[:input] == false && allow_server_managed != true
|
|
294
|
+
unless valid_field
|
|
295
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid increment field #{field}; expected a mutable numeric field")
|
|
296
|
+
end
|
|
297
|
+
if !delta.is_a?(Numeric) || (delta.respond_to?(:finite?) && !delta.finite?)
|
|
298
|
+
raise APIError.new("BAD_REQUEST", message: "Increment delta for #{field} must be numeric")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
result[logical_field] = delta
|
|
302
|
+
end
|
|
303
|
+
assignments = if set.nil? || set.empty?
|
|
304
|
+
{}
|
|
305
|
+
else
|
|
306
|
+
ensure_update_input_has_fields!(model, set)
|
|
307
|
+
transform_input(model, set, "update", true)
|
|
308
|
+
end
|
|
309
|
+
increments.reject! { |field, _delta| assignments.key?(field) }
|
|
310
|
+
if increments.empty? && assignments.empty?
|
|
311
|
+
raise APIError.new("BAD_REQUEST", message: "increment_one requires a non-empty increment or set")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
[increments, assignments]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def atomic_assignments_sql(model, increments, assignments, params)
|
|
318
|
+
increment_sql = increments.map do |field, delta|
|
|
319
|
+
column = quote(storage_field(model, field))
|
|
320
|
+
params << delta
|
|
321
|
+
"#{column} = COALESCE(#{column}, 0) + #{placeholder(params.length)}"
|
|
322
|
+
end
|
|
323
|
+
set_sql = assignments.map do |field, value|
|
|
324
|
+
params << value
|
|
325
|
+
"#{quote(storage_field(model, field))} = #{placeholder(params.length)}"
|
|
326
|
+
end
|
|
327
|
+
(increment_sql + set_sql).join(", ")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def atomic_schema_field(fields, field)
|
|
331
|
+
candidate = storage_key(field)
|
|
332
|
+
return candidate if fields.key?(candidate)
|
|
333
|
+
|
|
334
|
+
fields.find { |logical, attributes| storage_key(attributes[:field_name] || logical) == candidate }&.first
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def create_if_absent_mysql(model, field, input, table, columns, values, params)
|
|
338
|
+
savepoint = "better_auth_create_if_absent"
|
|
339
|
+
execute("SAVEPOINT #{savepoint}", [])
|
|
340
|
+
begin
|
|
341
|
+
execute("INSERT INTO #{table} (#{columns}) VALUES (#{values})", params)
|
|
342
|
+
execute("RELEASE SAVEPOINT #{savepoint}", [])
|
|
343
|
+
true
|
|
344
|
+
rescue => error
|
|
345
|
+
execute("ROLLBACK TO SAVEPOINT #{savepoint}", [])
|
|
346
|
+
execute("RELEASE SAVEPOINT #{savepoint}", [])
|
|
347
|
+
existing = find_one(model: model, where: [{field: field, value: input.fetch(field)}])
|
|
348
|
+
return false if mysql_duplicate_error?(error) && existing
|
|
349
|
+
|
|
350
|
+
raise error
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def mysql_duplicate_error?(error)
|
|
355
|
+
(error.respond_to?(:error_number) && error.error_number == 1062) ||
|
|
356
|
+
(error.respond_to?(:errno) && error.errno == 1062)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def atomic_lookup_field(model)
|
|
360
|
+
fields = schema_for(model).fetch(:fields)
|
|
361
|
+
return "id" if fields.key?("id")
|
|
362
|
+
|
|
363
|
+
unique = fields.find { |_field, attributes| attributes[:unique] }
|
|
364
|
+
return unique.first if unique
|
|
365
|
+
|
|
366
|
+
raise Error, "#{self.class} cannot atomically identify a #{model} row without an id or unique field"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def ensure_numeric_affected_rows!(value, operation)
|
|
370
|
+
return value if value.is_a?(Numeric)
|
|
371
|
+
|
|
372
|
+
raise Error, "#{self.class} returned a non-numeric affected-row result from #{operation}"
|
|
373
|
+
end
|
|
374
|
+
|
|
145
375
|
def transform_input(model, data, action, force_allow_id)
|
|
146
376
|
fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
|
|
147
377
|
input = stringify_keys(data)
|
|
@@ -266,46 +496,51 @@ module BetterAuth
|
|
|
266
496
|
end
|
|
267
497
|
|
|
268
498
|
def build_where(model, where, params)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
499
|
+
and_clauses, or_clauses = grouped_where_clauses(where)
|
|
500
|
+
and_sql = and_clauses.map { |clause| build_where_clause(model, clause, params) }
|
|
501
|
+
or_sql = or_clauses.map { |clause| build_where_clause(model, clause, params) }
|
|
502
|
+
return and_sql.join(" AND ") if or_sql.empty?
|
|
503
|
+
return or_sql.join(" OR ") if and_sql.empty?
|
|
504
|
+
|
|
505
|
+
"(#{and_sql.join(" AND ")}) AND (#{or_sql.join(" OR ")})"
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def build_where_clause(model, clause, params)
|
|
509
|
+
field = storage_key(fetch_key(clause, :field))
|
|
510
|
+
column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
|
|
511
|
+
operator = (fetch_key(clause, :operator) || "eq").to_s
|
|
512
|
+
value = fetch_key(clause, :value)
|
|
513
|
+
attributes = schema_for(model).fetch(:fields).fetch(field)
|
|
514
|
+
insensitive = insensitive_string_predicate?(clause, attributes)
|
|
515
|
+
predicate_column = insensitive ? "LOWER(#{column})" : column
|
|
516
|
+
|
|
517
|
+
if value.nil? && %w[eq ne].include?(operator)
|
|
518
|
+
null_operator = (operator == "ne") ? "IS NOT NULL" : "IS NULL"
|
|
519
|
+
"#{column} #{null_operator}"
|
|
520
|
+
else
|
|
521
|
+
case operator
|
|
522
|
+
when "in", "not_in"
|
|
523
|
+
values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
|
|
524
|
+
placeholders = values.map do |entry|
|
|
525
|
+
params << entry
|
|
526
|
+
placeholder(params.length)
|
|
527
|
+
end.join(", ")
|
|
528
|
+
sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
|
|
529
|
+
"#{predicate_column} #{sql_operator} (#{placeholders})"
|
|
530
|
+
when "contains", "starts_with", "ends_with"
|
|
531
|
+
escaped = escape_like(insensitive ? value.to_s.downcase : value)
|
|
532
|
+
pattern = case operator
|
|
533
|
+
when "starts_with" then "#{escaped}%"
|
|
534
|
+
when "ends_with" then "%#{escaped}"
|
|
535
|
+
else "%#{escaped}%"
|
|
303
536
|
end
|
|
537
|
+
params << pattern
|
|
538
|
+
"#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
|
|
539
|
+
else
|
|
540
|
+
params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
|
|
541
|
+
"#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
|
|
304
542
|
end
|
|
305
|
-
|
|
306
|
-
connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
|
|
307
|
-
index.zero? ? expression : "#{connector} #{expression}"
|
|
308
|
-
end.join(" ")
|
|
543
|
+
end
|
|
309
544
|
end
|
|
310
545
|
|
|
311
546
|
def order_sql(model, sort_by)
|
|
@@ -387,13 +622,18 @@ module BetterAuth
|
|
|
387
622
|
end
|
|
388
623
|
|
|
389
624
|
def affected_rows(result)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
625
|
+
value = if result.respond_to?(:cmd_tuples)
|
|
626
|
+
result.cmd_tuples
|
|
627
|
+
elsif result.respond_to?(:affected_rows)
|
|
628
|
+
result.affected_rows
|
|
629
|
+
elsif result.is_a?(Numeric)
|
|
630
|
+
result
|
|
631
|
+
elsif connection.respond_to?(:affected_rows)
|
|
632
|
+
connection.affected_rows
|
|
633
|
+
elsif connection.respond_to?(:changes)
|
|
634
|
+
connection.changes
|
|
635
|
+
end
|
|
636
|
+
ensure_numeric_affected_rows!(value, "SQL mutation")
|
|
397
637
|
end
|
|
398
638
|
|
|
399
639
|
def normalize_record(model, row, join: nil)
|
|
@@ -440,6 +680,8 @@ module BetterAuth
|
|
|
440
680
|
if config[:relation] == "one-to-one" || config[:unique] == true
|
|
441
681
|
grouped[key][join_model] = joined
|
|
442
682
|
else
|
|
683
|
+
next if grouped[key][join_model].length >= join_limit(config)
|
|
684
|
+
|
|
443
685
|
grouped[key][join_model] << joined
|
|
444
686
|
end
|
|
445
687
|
end
|
|
@@ -500,7 +742,10 @@ module BetterAuth
|
|
|
500
742
|
def coerce_value(value, attributes)
|
|
501
743
|
return value if value.nil?
|
|
502
744
|
return value ? 1 : 0 if dialect == :sqlite && attributes[:type] == "boolean"
|
|
503
|
-
|
|
745
|
+
if dialect == :sqlite && attributes[:type] == "date"
|
|
746
|
+
value = Time.parse(value) if value.is_a?(String)
|
|
747
|
+
return value.iso8601(6) if value.respond_to?(:iso8601)
|
|
748
|
+
end
|
|
504
749
|
return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
|
|
505
750
|
return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
|
|
506
751
|
return value.encode(Encoding::UTF_8) if attributes[:type] == "string" && value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT
|
data/lib/better_auth/api.rb
CHANGED
|
@@ -70,15 +70,7 @@ module BetterAuth
|
|
|
70
70
|
def prepare_context_for_call!(request, headers)
|
|
71
71
|
return unless context.respond_to?(:prepare_for_api_call!)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
if context.options.dynamic_base_url? && source.nil? && !dynamic_base_url_fallback?
|
|
75
|
-
raise APIError.new(
|
|
76
|
-
"INTERNAL_SERVER_ERROR",
|
|
77
|
-
message: "Dynamic baseURL could not be resolved for this direct auth.api call. Pass `headers: request.headers` (or `request`) to the call, or add `fallback` to your baseURL config."
|
|
78
|
-
)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
context.prepare_for_api_call!(source)
|
|
73
|
+
context.prepare_for_api_call!(direct_call_source(request, headers))
|
|
82
74
|
end
|
|
83
75
|
|
|
84
76
|
def direct_call_source(request, headers)
|
|
@@ -88,13 +80,6 @@ module BetterAuth
|
|
|
88
80
|
nil
|
|
89
81
|
end
|
|
90
82
|
|
|
91
|
-
def dynamic_base_url_fallback?
|
|
92
|
-
config = context.options.base_url_config
|
|
93
|
-
return false unless config.is_a?(Hash)
|
|
94
|
-
|
|
95
|
-
!!(config[:fallback] || config["fallback"])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
83
|
def run_endpoint_with_hooks(endpoint, endpoint_context)
|
|
99
84
|
before = run_before_hooks(endpoint_context)
|
|
100
85
|
return normalize_short_circuit(before, endpoint_context) if before
|
|
@@ -114,11 +99,7 @@ module BetterAuth
|
|
|
114
99
|
endpoint_context.returned = result.response
|
|
115
100
|
endpoint_context.response_headers = result.headers.dup
|
|
116
101
|
|
|
117
|
-
|
|
118
|
-
result.response = after_result.response
|
|
119
|
-
result.headers = after_result.headers
|
|
120
|
-
result.status = after_result.status if after_result.status
|
|
121
|
-
result
|
|
102
|
+
run_after_hooks(endpoint_context)
|
|
122
103
|
rescue APIError => error
|
|
123
104
|
Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
|
|
124
105
|
end
|
|
@@ -163,9 +144,7 @@ module BetterAuth
|
|
|
163
144
|
next unless hook_result
|
|
164
145
|
|
|
165
146
|
normalized = Endpoint::Result.from_value(hook_result, endpoint_context)
|
|
166
|
-
result
|
|
167
|
-
result.status = normalized.status
|
|
168
|
-
result.headers = normalized.headers
|
|
147
|
+
result = normalized
|
|
169
148
|
endpoint_context.returned = result.response
|
|
170
149
|
endpoint_context.response_headers = result.headers
|
|
171
150
|
end
|
|
@@ -31,7 +31,7 @@ module BetterAuth
|
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
33
|
attr_reader :app_name,
|
|
34
|
-
:
|
|
34
|
+
:base_url,
|
|
35
35
|
:base_path,
|
|
36
36
|
:context_base_url,
|
|
37
37
|
:secret,
|
|
@@ -39,6 +39,7 @@ module BetterAuth
|
|
|
39
39
|
:database,
|
|
40
40
|
:plugins,
|
|
41
41
|
:trusted_origins,
|
|
42
|
+
:serving_origins,
|
|
42
43
|
:rate_limit,
|
|
43
44
|
:session,
|
|
44
45
|
:account,
|
|
@@ -48,6 +49,7 @@ module BetterAuth
|
|
|
48
49
|
:email_and_password,
|
|
49
50
|
:password_hasher,
|
|
50
51
|
:email_verification,
|
|
52
|
+
:configured_social_provider_ids,
|
|
51
53
|
:social_providers,
|
|
52
54
|
:experimental,
|
|
53
55
|
:secondary_storage,
|
|
@@ -60,6 +62,7 @@ module BetterAuth
|
|
|
60
62
|
:logger
|
|
61
63
|
|
|
62
64
|
def initialize(options = {})
|
|
65
|
+
@configured_social_provider_ids = extract_configured_social_provider_ids(options)
|
|
63
66
|
options = symbolize_keys(options)
|
|
64
67
|
@explicit_options = deep_dup(options)
|
|
65
68
|
|
|
@@ -88,8 +91,8 @@ module BetterAuth
|
|
|
88
91
|
@secret = legacy_secret || (test_environment? ? DEFAULT_SECRET : nil)
|
|
89
92
|
@secret_config = @secret
|
|
90
93
|
end
|
|
91
|
-
@base_url_config = options[:base_url]
|
|
92
94
|
@base_url, @context_base_url = normalize_base_url(options[:base_url])
|
|
95
|
+
@serving_origins = normalize_serving_origins(options[:serving_origins])
|
|
93
96
|
@session = normalize_session(options[:session])
|
|
94
97
|
@account = normalize_account(options[:account])
|
|
95
98
|
@user = symbolize_keys(options[:user] || {})
|
|
@@ -110,26 +113,17 @@ module BetterAuth
|
|
|
110
113
|
end
|
|
111
114
|
end
|
|
112
115
|
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
Thread.current[base_url_runtime_key] = value
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def clear_runtime_base_url!
|
|
122
|
-
Thread.current[base_url_runtime_key] = nil
|
|
116
|
+
def explicit_trusted_origins
|
|
117
|
+
configured = @explicit_options[:trusted_origins]
|
|
118
|
+
origins = configured.respond_to?(:call) ? [] : Array(configured).compact
|
|
119
|
+
origins.concat(env_trusted_origins)
|
|
120
|
+
origins.map(&:to_s).reject(&:empty?).uniq
|
|
123
121
|
end
|
|
124
122
|
|
|
125
123
|
def production?
|
|
126
124
|
production_environment?
|
|
127
125
|
end
|
|
128
126
|
|
|
129
|
-
def dynamic_base_url?
|
|
130
|
-
URLHelpers.dynamic_config?(base_url_config)
|
|
131
|
-
end
|
|
132
|
-
|
|
133
127
|
def to_h
|
|
134
128
|
{
|
|
135
129
|
app_name: app_name,
|
|
@@ -140,6 +134,7 @@ module BetterAuth
|
|
|
140
134
|
database: database,
|
|
141
135
|
plugins: plugins,
|
|
142
136
|
trusted_origins: trusted_origins,
|
|
137
|
+
serving_origins: serving_origins,
|
|
143
138
|
rate_limit: rate_limit,
|
|
144
139
|
session: session,
|
|
145
140
|
account: account,
|
|
@@ -164,7 +159,7 @@ module BetterAuth
|
|
|
164
159
|
normalized = symbolize_keys(defaults || {})
|
|
165
160
|
normalized.each do |key, value|
|
|
166
161
|
next unless respond_to?(key)
|
|
167
|
-
next if key == :database_hooks
|
|
162
|
+
next if key == :database_hooks || key == :configured_social_provider_ids
|
|
168
163
|
|
|
169
164
|
if key == :trusted_origins
|
|
170
165
|
merge_trusted_origins_default(value)
|
|
@@ -231,15 +226,12 @@ module BetterAuth
|
|
|
231
226
|
|
|
232
227
|
def normalize_base_url(value)
|
|
233
228
|
configured = value || env_base_url
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
validate_dynamic_base_url!(configured)
|
|
238
|
-
resolved = URLHelpers.resolve_base_url(configured, base_path)
|
|
239
|
-
return ["", ""] unless resolved
|
|
229
|
+
if configured.nil? || configured.to_s.empty?
|
|
230
|
+
raise Error, "base_url is required. Pass a canonical URL to BetterAuth.auth(base_url: ...) or set BETTER_AUTH_URL."
|
|
231
|
+
end
|
|
240
232
|
|
|
241
|
-
|
|
242
|
-
|
|
233
|
+
if configured.is_a?(Hash)
|
|
234
|
+
raise Error, "Dynamic base_url hashes are no longer supported. Configure a canonical base_url and list request-facing origins in serving_origins."
|
|
243
235
|
end
|
|
244
236
|
|
|
245
237
|
with_path = append_base_path(configured.to_s)
|
|
@@ -250,11 +242,6 @@ module BetterAuth
|
|
|
250
242
|
raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
|
|
251
243
|
end
|
|
252
244
|
|
|
253
|
-
def validate_dynamic_base_url!(value)
|
|
254
|
-
allowed_hosts = value[:allowed_hosts] || value["allowed_hosts"] || value[:allowedHosts] || value["allowedHosts"]
|
|
255
|
-
raise Error, "baseURL.allowedHosts cannot be empty" if allowed_hosts.respond_to?(:empty?) && allowed_hosts.empty?
|
|
256
|
-
end
|
|
257
|
-
|
|
258
245
|
def normalize_base_path(value)
|
|
259
246
|
return "" if value.nil? || value == "" || value == "/"
|
|
260
247
|
|
|
@@ -368,7 +355,11 @@ module BetterAuth
|
|
|
368
355
|
end
|
|
369
356
|
|
|
370
357
|
def normalize_plugins(value)
|
|
371
|
-
Array(value).compact.reject { |plugin| plugin == false }.map
|
|
358
|
+
Array(value).compact.reject { |plugin| plugin == false }.map do |plugin|
|
|
359
|
+
coerced = Plugin.coerce(plugin)
|
|
360
|
+
Plugins.ensure_plugin_loaded_for!(coerced)
|
|
361
|
+
coerced
|
|
362
|
+
end
|
|
372
363
|
end
|
|
373
364
|
|
|
374
365
|
def normalize_social_providers(value)
|
|
@@ -377,24 +368,32 @@ module BetterAuth
|
|
|
377
368
|
end
|
|
378
369
|
end
|
|
379
370
|
|
|
371
|
+
def extract_configured_social_provider_ids(options)
|
|
372
|
+
return [].freeze unless options.is_a?(Hash)
|
|
373
|
+
|
|
374
|
+
options.each_with_object([]) do |(key, providers), ids|
|
|
375
|
+
next unless normalize_key(key) == :social_providers && providers.is_a?(Hash)
|
|
376
|
+
|
|
377
|
+
ids.concat(providers.keys.map { |provider_id| provider_id.to_s.dup.freeze })
|
|
378
|
+
end.uniq.freeze
|
|
379
|
+
end
|
|
380
|
+
|
|
380
381
|
def normalize_trusted_origins(value)
|
|
381
|
-
origins =
|
|
382
|
-
origins << base_url unless base_url.nil? || base_url.empty?
|
|
383
|
-
origins.concat(dynamic_base_url_trusted_origins)
|
|
382
|
+
origins = serving_origins.dup
|
|
384
383
|
origins.concat(Array(value).compact) unless value.respond_to?(:call)
|
|
385
384
|
origins.concat(env_trusted_origins)
|
|
386
385
|
origins.map(&:to_s).reject(&:empty?).uniq
|
|
387
386
|
end
|
|
388
387
|
|
|
389
|
-
def
|
|
390
|
-
|
|
388
|
+
def normalize_serving_origins(value)
|
|
389
|
+
([base_url] + Array(value).compact).map do |origin|
|
|
390
|
+
normalized = origin.to_s.sub(%r{/+\z}, "")
|
|
391
|
+
unless normalized.match?(%r{\Ahttps?://[^/?#@]+\z}i)
|
|
392
|
+
raise Error, "Invalid serving origin: #{origin}. serving_origins entries must be full http(s) origins without a path."
|
|
393
|
+
end
|
|
391
394
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
Array(allowed_hosts).map do |host|
|
|
395
|
-
host = host.to_s
|
|
396
|
-
host.match?(%r{\Ahttps?://}i) ? host : "#{protocol}://#{host}"
|
|
397
|
-
end
|
|
395
|
+
normalized
|
|
396
|
+
end.uniq
|
|
398
397
|
end
|
|
399
398
|
|
|
400
399
|
def merge_trusted_origins_default(value)
|
|
@@ -479,10 +478,6 @@ module BetterAuth
|
|
|
479
478
|
end
|
|
480
479
|
end
|
|
481
480
|
|
|
482
|
-
def base_url_runtime_key
|
|
483
|
-
:"better_auth_configuration_base_url_#{object_id}"
|
|
484
|
-
end
|
|
485
|
-
|
|
486
481
|
def test_environment?
|
|
487
482
|
ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
|
|
488
483
|
end
|