better_auth 0.8.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 +92 -0
- data/LICENSE.md +1 -1
- data/README.md +142 -95
- 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 +303 -54
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +390 -63
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +48 -47
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +119 -15
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +108 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/http_client.rb +46 -0
- 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 +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- 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 +85 -29
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- 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 +77 -5
- data/lib/better_auth/plugins/dub.rb +10 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +162 -140
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +44 -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 +233 -46
- 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 +78 -65
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +77 -9
- data/lib/better_auth/plugins/organization.rb +546 -102
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +25 -27
- 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 +205 -19
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +300 -133
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +44 -8
- data/lib/better_auth/routes/email_verification.rb +93 -6
- data/lib/better_auth/routes/password.rb +6 -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 +197 -54
- data/lib/better_auth/routes/user.rb +17 -10
- data/lib/better_auth/schema/sql.rb +189 -34
- data/lib/better_auth/schema.rb +159 -5
- data/lib/better_auth/session.rb +29 -10
- 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 +45 -23
- 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/github.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 +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +14 -41
- metadata +14 -18
- 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 -215
- data/lib/better_auth/plugins/oidc_provider.rb +0 -710
|
@@ -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,36 @@ module BetterAuth
|
|
|
30
32
|
row = rows.first
|
|
31
33
|
return normalize_record(model, row) if row
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
lookup = create_lookup(model, input)
|
|
36
|
+
lookup ? find_one(model: model, where: [lookup]) : input
|
|
37
|
+
end
|
|
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
|
|
34
65
|
end
|
|
35
66
|
|
|
36
67
|
def find_one(model:, where: [], select: nil, join: nil)
|
|
@@ -61,21 +92,29 @@ module BetterAuth
|
|
|
61
92
|
|
|
62
93
|
def update(model:, where:, update:)
|
|
63
94
|
model = model.to_s
|
|
95
|
+
return nil if Array(where).empty?
|
|
96
|
+
|
|
97
|
+
ensure_update_input_has_fields!(model, update)
|
|
64
98
|
if dialect == :postgres
|
|
65
99
|
records = update_many(model: model, where: where, update: update, returning: true)
|
|
66
100
|
return records.is_a?(Array) ? records.first : records
|
|
67
101
|
end
|
|
68
102
|
|
|
69
|
-
existing = find_one(model: model, where: where
|
|
103
|
+
existing = find_one(model: model, where: where)
|
|
70
104
|
return nil unless existing
|
|
71
105
|
|
|
72
|
-
update_many(model: model, where: where, update: update)
|
|
73
|
-
|
|
106
|
+
updated_count = update_many(model: model, where: where, update: update)
|
|
107
|
+
return nil unless updated_count.to_i.positive?
|
|
108
|
+
|
|
109
|
+
lookup = record_lookup(model, existing)
|
|
110
|
+
lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
|
|
74
111
|
end
|
|
75
112
|
|
|
76
113
|
def update_many(model:, where:, update:, returning: false)
|
|
77
114
|
model = model.to_s
|
|
115
|
+
ensure_update_input_has_fields!(model, update)
|
|
78
116
|
data = transform_input(model, update, "update", true)
|
|
117
|
+
ensure_update_data!(data)
|
|
79
118
|
params = []
|
|
80
119
|
assignments = data.each_key.map do |field|
|
|
81
120
|
params << data[field]
|
|
@@ -87,11 +126,11 @@ module BetterAuth
|
|
|
87
126
|
sql << " SET "
|
|
88
127
|
sql << assignments.join(", ")
|
|
89
128
|
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
90
|
-
sql << " RETURNING *" if dialect == :postgres
|
|
91
|
-
|
|
92
|
-
return
|
|
129
|
+
sql << " RETURNING *" if dialect == :postgres && returning
|
|
130
|
+
result = execute(sql, params, affected_rows_result: !returning)
|
|
131
|
+
return result.map { |row| normalize_record(model, row) } if returning
|
|
93
132
|
|
|
94
|
-
|
|
133
|
+
affected_rows(result)
|
|
95
134
|
end
|
|
96
135
|
|
|
97
136
|
def delete(model:, where:)
|
|
@@ -99,14 +138,15 @@ module BetterAuth
|
|
|
99
138
|
nil
|
|
100
139
|
end
|
|
101
140
|
|
|
102
|
-
def delete_many(model:, where:)
|
|
141
|
+
def delete_many(model:, where:, limit: nil)
|
|
103
142
|
model = model.to_s
|
|
104
143
|
params = []
|
|
105
144
|
where_sql = build_where(model, where || [], params)
|
|
106
145
|
sql = +"DELETE FROM "
|
|
107
146
|
sql << quote(table_for(model))
|
|
108
147
|
sql << " WHERE #{where_sql}" unless where_sql.empty?
|
|
109
|
-
|
|
148
|
+
sql << " LIMIT #{Integer(limit)}" if limit && dialect == :mysql
|
|
149
|
+
result = execute(sql, params, affected_rows_result: true)
|
|
110
150
|
affected_rows(result)
|
|
111
151
|
end
|
|
112
152
|
|
|
@@ -121,18 +161,217 @@ module BetterAuth
|
|
|
121
161
|
(row["count"] || row[:count] || 0).to_i
|
|
122
162
|
end
|
|
123
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
|
+
|
|
124
189
|
def transaction
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
190
|
+
return yield active_transaction_adapter if active_transaction_adapter
|
|
191
|
+
|
|
192
|
+
@connection_lock.synchronize do
|
|
193
|
+
execute("BEGIN", [])
|
|
194
|
+
result = with_transaction_context(self) { yield self }
|
|
195
|
+
execute("COMMIT", [])
|
|
196
|
+
result
|
|
197
|
+
rescue
|
|
198
|
+
execute("ROLLBACK", [])
|
|
199
|
+
raise
|
|
200
|
+
end
|
|
132
201
|
end
|
|
133
202
|
|
|
134
203
|
private
|
|
135
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
|
+
|
|
136
375
|
def transform_input(model, data, action, force_allow_id)
|
|
137
376
|
fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
|
|
138
377
|
input = stringify_keys(data)
|
|
@@ -160,10 +399,47 @@ module BetterAuth
|
|
|
160
399
|
output[field] = coerce_value(value, attributes) if value_provided
|
|
161
400
|
end
|
|
162
401
|
|
|
163
|
-
output["id"] = generated_id if action == "create" && !output.key?("id")
|
|
402
|
+
output["id"] = generated_id if action == "create" && !output.key?("id") && fields.key?("id")
|
|
164
403
|
output
|
|
165
404
|
end
|
|
166
405
|
|
|
406
|
+
def create_lookup(model, input)
|
|
407
|
+
fields = schema_for(model).fetch(:fields)
|
|
408
|
+
return {field: "id", value: input.fetch("id")} if fields.key?("id") && input.key?("id")
|
|
409
|
+
|
|
410
|
+
unique_field = fields.find { |field, attributes| attributes[:unique] && input.key?(field) }
|
|
411
|
+
return {field: unique_field.first, value: input.fetch(unique_field.first)} if unique_field
|
|
412
|
+
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def record_lookup(model, record)
|
|
417
|
+
fields = schema_for(model).fetch(:fields)
|
|
418
|
+
return {field: "id", value: record.fetch("id")} if fields.key?("id") && record.key?("id")
|
|
419
|
+
|
|
420
|
+
unique_field = fields.find { |field, attributes| attributes[:unique] && record.key?(field) }
|
|
421
|
+
return {field: unique_field.first, value: record.fetch(unique_field.first)} if unique_field
|
|
422
|
+
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def ensure_update_data!(data)
|
|
427
|
+
raise APIError.new("BAD_REQUEST", message: "No fields to update") if data.empty?
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def ensure_update_input_has_fields!(model, update)
|
|
431
|
+
raise APIError.new("BAD_REQUEST", message: "No fields to update") unless update.is_a?(Hash)
|
|
432
|
+
|
|
433
|
+
fields = schema_for(model).fetch(:fields)
|
|
434
|
+
input = stringify_keys(update)
|
|
435
|
+
has_updatable_field = input.any? do |field, _value|
|
|
436
|
+
next false if field == "id" || field == "_id"
|
|
437
|
+
|
|
438
|
+
fields.key?(field) || fields.any? { |logical_field, attributes| storage_key(attributes[:field_name] || logical_field) == field }
|
|
439
|
+
end
|
|
440
|
+
raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
|
|
441
|
+
end
|
|
442
|
+
|
|
167
443
|
def select_sql(model, select, join)
|
|
168
444
|
fields = Array(select).empty? ? schema_for(model).fetch(:fields).keys : Array(select).map { |field| storage_key(field) }
|
|
169
445
|
columns = fields.map do |field|
|
|
@@ -220,39 +496,51 @@ module BetterAuth
|
|
|
220
496
|
end
|
|
221
497
|
|
|
222
498
|
def build_where(model, where, params)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
231
522
|
when "in", "not_in"
|
|
232
|
-
values = Array(value).map { |entry| coerce_where_value(entry, attributes) }
|
|
523
|
+
values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
|
|
233
524
|
placeholders = values.map do |entry|
|
|
234
525
|
params << entry
|
|
235
526
|
placeholder(params.length)
|
|
236
527
|
end.join(", ")
|
|
237
528
|
sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
|
|
238
|
-
"#{
|
|
529
|
+
"#{predicate_column} #{sql_operator} (#{placeholders})"
|
|
239
530
|
when "contains", "starts_with", "ends_with"
|
|
240
|
-
escaped = escape_like(value)
|
|
531
|
+
escaped = escape_like(insensitive ? value.to_s.downcase : value)
|
|
241
532
|
pattern = case operator
|
|
242
533
|
when "starts_with" then "#{escaped}%"
|
|
243
534
|
when "ends_with" then "%#{escaped}"
|
|
244
535
|
else "%#{escaped}%"
|
|
245
536
|
end
|
|
246
537
|
params << pattern
|
|
247
|
-
"#{
|
|
538
|
+
"#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
|
|
248
539
|
else
|
|
249
|
-
params << coerce_where_value(value, attributes)
|
|
250
|
-
"#{
|
|
540
|
+
params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
|
|
541
|
+
"#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
|
|
251
542
|
end
|
|
252
|
-
|
|
253
|
-
connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
|
|
254
|
-
index.zero? ? expression : "#{connector} #{expression}"
|
|
255
|
-
end.join(" ")
|
|
543
|
+
end
|
|
256
544
|
end
|
|
257
545
|
|
|
258
546
|
def order_sql(model, sort_by)
|
|
@@ -286,34 +574,66 @@ module BetterAuth
|
|
|
286
574
|
}.fetch(operator, "=")
|
|
287
575
|
end
|
|
288
576
|
|
|
289
|
-
def
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
577
|
+
def insensitive_string_predicate?(clause, attributes)
|
|
578
|
+
fetch_key(clause, :mode).to_s == "insensitive" && attributes[:type] == "string"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def execute(sql, params, affected_rows_result: false)
|
|
582
|
+
@connection_lock.synchronize do
|
|
583
|
+
if connection.respond_to?(:exec_params)
|
|
584
|
+
result = connection.exec_params(sql, params)
|
|
585
|
+
return affected_rows_result ? result : [] if result.respond_to?(:fields) && result.fields.empty?
|
|
586
|
+
return result.to_a if result.respond_to?(:to_a)
|
|
587
|
+
|
|
588
|
+
result
|
|
589
|
+
elsif connection.respond_to?(:query) && params.empty?
|
|
590
|
+
result = connection.query(sql)
|
|
591
|
+
result.respond_to?(:to_a) ? result.to_a : result
|
|
592
|
+
elsif dialect == :sqlite && connection.respond_to?(:execute)
|
|
593
|
+
result = connection.execute(sql, params)
|
|
594
|
+
result.respond_to?(:to_a) ? result.to_a : result
|
|
595
|
+
elsif connection.respond_to?(:prepare)
|
|
596
|
+
statement = connection.prepare(sql)
|
|
597
|
+
result = nil
|
|
598
|
+
begin
|
|
599
|
+
result = statement.execute(*params)
|
|
600
|
+
if result.nil?
|
|
601
|
+
return [] unless affected_rows_result
|
|
602
|
+
return statement.affected_rows if statement.respond_to?(:affected_rows)
|
|
603
|
+
return connection.affected_rows if connection.respond_to?(:affected_rows)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
rows = result.respond_to?(:to_a) ? result.to_a : result
|
|
607
|
+
rows
|
|
608
|
+
ensure
|
|
609
|
+
if result.respond_to?(:close)
|
|
610
|
+
result.close
|
|
611
|
+
elsif statement.respond_to?(:close)
|
|
612
|
+
statement.close
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
elsif connection.respond_to?(:execute)
|
|
616
|
+
result = connection.execute(sql, params)
|
|
617
|
+
result.respond_to?(:to_a) ? result.to_a : result
|
|
618
|
+
else
|
|
619
|
+
raise Error, "SQL connection must respond to exec_params or prepare"
|
|
620
|
+
end
|
|
307
621
|
end
|
|
308
622
|
end
|
|
309
623
|
|
|
310
624
|
def affected_rows(result)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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")
|
|
317
637
|
end
|
|
318
638
|
|
|
319
639
|
def normalize_record(model, row, join: nil)
|
|
@@ -360,6 +680,8 @@ module BetterAuth
|
|
|
360
680
|
if config[:relation] == "one-to-one" || config[:unique] == true
|
|
361
681
|
grouped[key][join_model] = joined
|
|
362
682
|
else
|
|
683
|
+
next if grouped[key][join_model].length >= join_limit(config)
|
|
684
|
+
|
|
363
685
|
grouped[key][join_model] << joined
|
|
364
686
|
end
|
|
365
687
|
end
|
|
@@ -406,11 +728,11 @@ module BetterAuth
|
|
|
406
728
|
end
|
|
407
729
|
|
|
408
730
|
def escape_like(value)
|
|
409
|
-
value.to_s.gsub(/[
|
|
731
|
+
value.to_s.gsub(/[!%_]/) { |match| "!#{match}" }
|
|
410
732
|
end
|
|
411
733
|
|
|
412
734
|
def escape_literal
|
|
413
|
-
|
|
735
|
+
"'!'"
|
|
414
736
|
end
|
|
415
737
|
|
|
416
738
|
def resolve_default(default)
|
|
@@ -420,9 +742,13 @@ module BetterAuth
|
|
|
420
742
|
def coerce_value(value, attributes)
|
|
421
743
|
return value if value.nil?
|
|
422
744
|
return value ? 1 : 0 if dialect == :sqlite && attributes[:type] == "boolean"
|
|
423
|
-
|
|
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
|
|
424
749
|
return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
|
|
425
750
|
return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
|
|
751
|
+
return value.encode(Encoding::UTF_8) if attributes[:type] == "string" && value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT
|
|
426
752
|
|
|
427
753
|
value
|
|
428
754
|
end
|
|
@@ -446,6 +772,7 @@ module BetterAuth
|
|
|
446
772
|
def coerce_output_value(value, attributes)
|
|
447
773
|
return value if value.nil?
|
|
448
774
|
return coerce_boolean(value) if attributes[:type] == "boolean"
|
|
775
|
+
return coerce_number(value) if attributes[:type] == "number"
|
|
449
776
|
return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
|
|
450
777
|
return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
|
|
451
778
|
|
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
|