better_auth 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+ require "time"
6
+
7
+ module BetterAuth
8
+ module Adapters
9
+ class SQL < Base
10
+ attr_reader :connection, :dialect
11
+
12
+ def initialize(options, connection:, dialect:)
13
+ super(options)
14
+ @connection = connection
15
+ @dialect = dialect.to_sym
16
+ end
17
+
18
+ def create(model:, data:, force_allow_id: false)
19
+ model = model.to_s
20
+ input = transform_input(model, data, "create", force_allow_id)
21
+ table = table_for(model)
22
+ columns = input.keys.map { |field| storage_field(model, field) }
23
+ params = input.keys.map { |field| input[field] }
24
+ placeholders = params.each_index.map { |index| placeholder(index + 1) }
25
+ returning = (dialect == :postgres) ? " RETURNING *" : ""
26
+ sql = "INSERT INTO #{quote(table)} (#{columns.map { |column| quote(column) }.join(", ")}) VALUES (#{placeholders.join(", ")})#{returning}"
27
+ rows = execute(sql, params)
28
+ row = rows.first
29
+ return normalize_record(model, row) if row
30
+
31
+ find_one(model: model, where: [{field: "id", value: input.fetch("id")}])
32
+ end
33
+
34
+ def find_one(model:, where: [], select: nil, join: nil)
35
+ if collection_join?(model.to_s, join)
36
+ find_many(model: model, where: where, select: select, join: join).first
37
+ else
38
+ find_many(model: model, where: where, select: select, join: join, limit: 1).first
39
+ end
40
+ end
41
+
42
+ def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
43
+ model = model.to_s
44
+ params = []
45
+ sql = +"SELECT "
46
+ sql << "TOP (#{Integer(limit)}) " if dialect == :mssql && limit && !offset
47
+ sql << select_sql(model, select, join)
48
+ sql << " FROM "
49
+ sql << quote(table_for(model))
50
+ sql << join_sql(model, join)
51
+ where_sql = build_where(model, where || [], params)
52
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
53
+ sql << order_sql(model, sort_by) if sort_by
54
+ append_pagination_sql(sql, model, sort_by, limit, offset)
55
+
56
+ records = execute(sql, params).map { |row| normalize_record(model, row, join: join) }
57
+ collection_join?(model, join) ? aggregate_collection_joins(model, records, join) : records
58
+ end
59
+
60
+ def update(model:, where:, update:)
61
+ model = model.to_s
62
+ if dialect == :postgres
63
+ records = update_many(model: model, where: where, update: update, returning: true)
64
+ return records.is_a?(Array) ? records.first : records
65
+ end
66
+
67
+ existing = find_one(model: model, where: where, select: ["id"])
68
+ return nil unless existing
69
+
70
+ update_many(model: model, where: where, update: update)
71
+ find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
72
+ end
73
+
74
+ def update_many(model:, where:, update:, returning: false)
75
+ model = model.to_s
76
+ data = transform_input(model, update, "update", true)
77
+ params = []
78
+ assignments = data.each_key.map do |field|
79
+ params << data[field]
80
+ "#{quote(storage_field(model, field))} = #{placeholder(params.length)}"
81
+ end
82
+ where_sql = build_where(model, where || [], params)
83
+ sql = +"UPDATE "
84
+ sql << quote(table_for(model))
85
+ sql << " SET "
86
+ sql << assignments.join(", ")
87
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
88
+ sql << " RETURNING *" if dialect == :postgres
89
+ rows = execute(sql, params).map { |row| normalize_record(model, row) }
90
+ return rows if returning || dialect == :postgres
91
+
92
+ nil
93
+ end
94
+
95
+ def delete(model:, where:)
96
+ delete_many(model: model, where: where)
97
+ nil
98
+ end
99
+
100
+ def delete_many(model:, where:)
101
+ model = model.to_s
102
+ params = []
103
+ where_sql = build_where(model, where || [], params)
104
+ sql = +"DELETE FROM "
105
+ sql << quote(table_for(model))
106
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
107
+ result = execute(sql, params)
108
+ affected_rows(result)
109
+ end
110
+
111
+ def count(model:, where: nil)
112
+ model = model.to_s
113
+ params = []
114
+ where_sql = build_where(model, where || [], params)
115
+ sql = +"SELECT COUNT(*) AS count FROM "
116
+ sql << quote(table_for(model))
117
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
118
+ row = execute(sql, params).first || {}
119
+ (row["count"] || row[:count] || 0).to_i
120
+ end
121
+
122
+ def transaction
123
+ execute("BEGIN", [])
124
+ result = yield self
125
+ execute("COMMIT", [])
126
+ result
127
+ rescue
128
+ execute("ROLLBACK", [])
129
+ raise
130
+ end
131
+
132
+ private
133
+
134
+ def transform_input(model, data, action, force_allow_id)
135
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
136
+ input = stringify_keys(data)
137
+ output = {}
138
+
139
+ fields.each do |field, attributes|
140
+ next if field == "id" && input.key?(field) && !force_allow_id
141
+
142
+ value_provided = input.key?(field)
143
+ value = input[field]
144
+ if value_provided && attributes[:input] == false && value && !force_allow_id
145
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
146
+ end
147
+
148
+ if !value_provided && action == "create" && attributes.key?(:default_value)
149
+ value = resolve_default(attributes[:default_value])
150
+ value_provided = true
151
+ elsif !value_provided && action == "update" && attributes[:on_update]
152
+ value = resolve_default(attributes[:on_update])
153
+ value_provided = true
154
+ end
155
+ if !value_provided && action == "create" && attributes[:required]
156
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
157
+ end
158
+ output[field] = coerce_value(value, attributes) if value_provided
159
+ end
160
+
161
+ output["id"] = generated_id if action == "create" && !output.key?("id")
162
+ output
163
+ end
164
+
165
+ def select_sql(model, select, join)
166
+ fields = Array(select).empty? ? schema_for(model).fetch(:fields).keys : Array(select).map { |field| storage_key(field) }
167
+ columns = fields.map do |field|
168
+ column = storage_field(model, field)
169
+ "#{quote(table_for(model))}.#{quote(column)} AS #{quote(column)}"
170
+ end
171
+ columns.concat(join_select_sql(model, join)) if join
172
+ columns.join(", ")
173
+ end
174
+
175
+ def join_select_sql(model, join)
176
+ join.flat_map do |join_model, _enabled|
177
+ join_model = join_model.to_s
178
+ schema_for(join_model).fetch(:fields).map do |field, attributes|
179
+ column = attributes[:field_name] || physical_name(field)
180
+ "#{quote(join_model)}.#{quote(column)} AS #{quote("#{join_model}__#{column}")}"
181
+ end
182
+ end
183
+ end
184
+
185
+ def join_sql(model, join)
186
+ return "" unless join
187
+
188
+ join.map do |join_model, _enabled|
189
+ join_model = join_model.to_s
190
+ case [model, join_model]
191
+ when ["session", "user"], ["account", "user"]
192
+ " LEFT JOIN #{quote(table_for("user"))} AS #{quote("user")} ON #{quote("user")}.#{quote("id")} = #{quote(table_for(model))}.#{quote("user_id")}"
193
+ when ["user", "account"]
194
+ " LEFT JOIN #{quote(table_for("account"))} AS #{quote("account")} ON #{quote("account")}.#{quote("user_id")} = #{quote(table_for(model))}.#{quote("id")}"
195
+ else
196
+ ""
197
+ end
198
+ end.join
199
+ end
200
+
201
+ def build_where(model, where, params)
202
+ Array(where).each_with_index.map do |clause, index|
203
+ field = storage_key(fetch_key(clause, :field))
204
+ column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
205
+ operator = (fetch_key(clause, :operator) || "eq").to_s
206
+ value = fetch_key(clause, :value)
207
+
208
+ expression = case operator
209
+ when "in", "not_in"
210
+ values = Array(value)
211
+ placeholders = values.map do |entry|
212
+ params << entry
213
+ placeholder(params.length)
214
+ end.join(", ")
215
+ sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
216
+ "#{column} #{sql_operator} (#{placeholders})"
217
+ when "contains", "starts_with", "ends_with"
218
+ pattern = case operator
219
+ when "starts_with" then "#{value}%"
220
+ when "ends_with" then "%#{value}"
221
+ else "%#{value}%"
222
+ end
223
+ params << pattern
224
+ "#{column} LIKE #{placeholder(params.length)}"
225
+ else
226
+ params << value
227
+ "#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
228
+ end
229
+
230
+ connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
231
+ index.zero? ? expression : "#{connector} #{expression}"
232
+ end.join(" ")
233
+ end
234
+
235
+ def order_sql(model, sort_by)
236
+ field = Schema.storage_key(fetch_key(sort_by, :field))
237
+ direction = (fetch_key(sort_by, :direction).to_s.downcase == "desc") ? "DESC" : "ASC"
238
+ " ORDER BY #{quote(table_for(model))}.#{quote(storage_field(model, field))} #{direction}"
239
+ end
240
+
241
+ def append_pagination_sql(sql, model, sort_by, limit, offset)
242
+ if dialect == :mssql
243
+ return if limit && !offset
244
+ return unless offset
245
+
246
+ sql << order_sql(model, {field: "id", direction: "asc"}) unless sort_by
247
+ sql << " OFFSET #{Integer(offset)} ROWS"
248
+ sql << " FETCH NEXT #{Integer(limit)} ROWS ONLY" if limit
249
+ return
250
+ end
251
+
252
+ sql << " LIMIT #{Integer(limit)}" if limit
253
+ sql << " OFFSET #{Integer(offset)}" if offset
254
+ end
255
+
256
+ def sql_operator(operator)
257
+ {
258
+ "ne" => "!=",
259
+ "gt" => ">",
260
+ "gte" => ">=",
261
+ "lt" => "<",
262
+ "lte" => "<="
263
+ }.fetch(operator, "=")
264
+ end
265
+
266
+ def execute(sql, params)
267
+ if connection.respond_to?(:exec_params)
268
+ result = connection.exec_params(sql, params)
269
+ return result.to_a if result.respond_to?(:to_a)
270
+
271
+ result
272
+ elsif connection.respond_to?(:query) && params.empty?
273
+ result = connection.query(sql)
274
+ result.respond_to?(:to_a) ? result.to_a : result
275
+ elsif connection.respond_to?(:prepare)
276
+ statement = connection.prepare(sql)
277
+ result = statement.execute(*params)
278
+ result.respond_to?(:to_a) ? result.to_a : result
279
+ elsif connection.respond_to?(:execute)
280
+ result = connection.execute(sql, params)
281
+ result.respond_to?(:to_a) ? result.to_a : result
282
+ else
283
+ raise Error, "SQL connection must respond to exec_params or prepare"
284
+ end
285
+ end
286
+
287
+ def affected_rows(result)
288
+ return result.cmd_tuples if result.respond_to?(:cmd_tuples)
289
+ return result.affected_rows if result.respond_to?(:affected_rows)
290
+ return connection.changes if connection.respond_to?(:changes)
291
+ return result.to_i if result.respond_to?(:to_i)
292
+
293
+ 0
294
+ end
295
+
296
+ def normalize_record(model, row, join: nil)
297
+ return nil unless row
298
+
299
+ fields = schema_for(model).fetch(:fields)
300
+ record = fields.each_with_object({}) do |(field, attributes), output|
301
+ column = attributes[:field_name] || physical_name(field)
302
+ output[field] = coerce_output_value(fetch_row(row, column), attributes) if row_key?(row, column)
303
+ end
304
+
305
+ join&.each_key do |join_model|
306
+ join_model = join_model.to_s
307
+ record[join_model] = normalize_joined_record(join_model, row)
308
+ end
309
+
310
+ record
311
+ end
312
+
313
+ def normalize_joined_record(model, row)
314
+ schema_for(model).fetch(:fields).each_with_object({}) do |(field, attributes), output|
315
+ column = attributes[:field_name] || physical_name(field)
316
+ key = "#{model}__#{column}"
317
+ output[field] = coerce_output_value(fetch_row(row, key), attributes) if row_key?(row, key)
318
+ end
319
+ end
320
+
321
+ def collection_join?(model, join)
322
+ model == "user" && join&.keys&.any? { |join_model| join_model.to_s == "account" }
323
+ end
324
+
325
+ def aggregate_collection_joins(_model, records, _join)
326
+ grouped = {}
327
+ records.each do |record|
328
+ key = record.fetch("id")
329
+ grouped[key] ||= record.merge("account" => [])
330
+ account = record["account"]
331
+ grouped[key]["account"] << account if account&.values&.any?
332
+ end
333
+ grouped.values
334
+ end
335
+
336
+ def row_key?(row, key)
337
+ row.key?(key) || row.key?(key.to_sym)
338
+ end
339
+
340
+ def fetch_row(row, key)
341
+ return row[key] if row.key?(key)
342
+
343
+ row[key.to_sym]
344
+ end
345
+
346
+ def table_for(model)
347
+ schema_for(model).fetch(:model_name)
348
+ end
349
+
350
+ def schema_for(model)
351
+ Schema.auth_tables(options).fetch(model.to_s)
352
+ end
353
+
354
+ def storage_field(model, field)
355
+ schema_for(model).fetch(:fields).fetch(field.to_s).fetch(:field_name, physical_name(field))
356
+ end
357
+
358
+ def quote(identifier)
359
+ Schema::SQL.quote(identifier, dialect)
360
+ end
361
+
362
+ def placeholder(index)
363
+ (dialect == :postgres) ? "$#{index}" : "?"
364
+ end
365
+
366
+ def generated_id
367
+ generator = options.advanced.dig(:database, :generate_id)
368
+ return generator.call.to_s if generator.respond_to?(:call)
369
+ return SecureRandom.uuid if generator == "uuid"
370
+
371
+ SecureRandom.hex(16)
372
+ end
373
+
374
+ def resolve_default(default)
375
+ default.respond_to?(:call) ? default.call : default
376
+ end
377
+
378
+ def coerce_value(value, attributes)
379
+ return value if value.nil?
380
+ return value ? 1 : 0 if dialect == :sqlite && attributes[:type] == "boolean"
381
+ return value.iso8601(6) if dialect == :sqlite && attributes[:type] == "date" && value.respond_to?(:iso8601)
382
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
383
+ return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
384
+
385
+ value
386
+ end
387
+
388
+ def coerce_output_value(value, attributes)
389
+ return value if value.nil?
390
+ return coerce_boolean(value) if attributes[:type] == "boolean"
391
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
392
+ return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
393
+
394
+ value
395
+ end
396
+
397
+ def json_like?(attributes)
398
+ %w[json string[] number[]].include?(attributes[:type])
399
+ end
400
+
401
+ def parse_json_value(value)
402
+ JSON.parse(value)
403
+ rescue JSON::ParserError
404
+ value
405
+ end
406
+
407
+ def coerce_boolean(value)
408
+ return value if value == true || value == false
409
+ return false if value == 0 || value.to_s == "0" || value.to_s.downcase == "f" || value.to_s.downcase == "false"
410
+ return true if value == 1 || value.to_s == "1" || value.to_s.downcase == "t" || value.to_s.downcase == "true"
411
+
412
+ value
413
+ end
414
+
415
+ def stringify_keys(data)
416
+ data.each_with_object({}) do |(key, value), result|
417
+ result[storage_key(key)] = value
418
+ end
419
+ end
420
+
421
+ def fetch_key(hash, key)
422
+ [key, key.to_s, storage_key(key), storage_key(key).to_sym].each do |candidate|
423
+ return hash[candidate] if hash.key?(candidate)
424
+ end
425
+ nil
426
+ end
427
+
428
+ def storage_key(value)
429
+ parts = physical_name(value).split("_")
430
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
431
+ end
432
+
433
+ def physical_name(value)
434
+ value.to_s
435
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
436
+ .tr("-", "_")
437
+ .downcase
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Adapters
5
+ class SQLite < SQL
6
+ attr_reader :path
7
+
8
+ def initialize(options = nil, path: nil, connection: nil)
9
+ require "sqlite3" unless connection
10
+
11
+ config = options || Configuration.new(secret: Configuration::DEFAULT_SECRET, database: :memory)
12
+ @path = path || ":memory:"
13
+ connection ||= SQLite3::Database.new(@path)
14
+ connection.results_as_hash = true if connection.respond_to?(:results_as_hash=)
15
+ connection.execute("PRAGMA foreign_keys = ON") if connection.respond_to?(:execute)
16
+ super(config, connection: connection, dialect: :sqlite)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ class API
5
+ attr_reader :context, :endpoints
6
+
7
+ def initialize(context, endpoints)
8
+ @context = context
9
+ @endpoints = endpoints
10
+ define_endpoint_methods
11
+ end
12
+
13
+ def call_endpoint(key, input = {})
14
+ context.reset_runtime! if context.respond_to?(:reset_runtime!)
15
+ endpoint = endpoints.fetch(key.to_sym)
16
+ input = symbolize_keys(input || {})
17
+ endpoint_context = Endpoint::Context.new(
18
+ path: endpoint.path,
19
+ method: input[:method] || Array(endpoint.methods).first,
20
+ query: input[:query] || {},
21
+ body: input[:body] || {},
22
+ params: input[:params] || {},
23
+ headers: input[:headers] || {},
24
+ context: context
25
+ )
26
+
27
+ result = run_endpoint_with_hooks(endpoint, endpoint_context)
28
+ format_result(result, input)
29
+ end
30
+
31
+ def execute(endpoint, endpoint_context)
32
+ run_endpoint_with_hooks(endpoint, endpoint_context)
33
+ end
34
+
35
+ private
36
+
37
+ def define_endpoint_methods
38
+ endpoints.each_key do |key|
39
+ method_name = normalize_method_name(key)
40
+ define_singleton_method(method_name) do |input = {}|
41
+ call_endpoint(key, input || {})
42
+ end
43
+ end
44
+ end
45
+
46
+ def normalize_method_name(key)
47
+ key.to_s
48
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
49
+ .tr("-", "_")
50
+ .downcase
51
+ .to_sym
52
+ end
53
+
54
+ def run_endpoint_with_hooks(endpoint, endpoint_context)
55
+ before = run_before_hooks(endpoint_context)
56
+ return normalize_short_circuit(before, endpoint_context) if before
57
+
58
+ result = begin
59
+ endpoint.call(endpoint_context)
60
+ rescue APIError => error
61
+ Endpoint::Result.new(
62
+ response: error,
63
+ status: error.status_code,
64
+ headers: Endpoint::Result.merge_headers(endpoint_context.response_headers, error.headers)
65
+ )
66
+ end
67
+
68
+ return result if result.raw_response?
69
+
70
+ endpoint_context.returned = result.response
71
+ endpoint_context.response_headers = result.headers.dup
72
+
73
+ after_result = run_after_hooks(endpoint_context)
74
+ result.response = after_result.response
75
+ result.headers = after_result.headers
76
+ result.status = after_result.status if after_result.status
77
+ result
78
+ rescue APIError => error
79
+ Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
80
+ end
81
+
82
+ def run_before_hooks(endpoint_context)
83
+ before_hooks.each do |hook|
84
+ next unless hook_matches?(hook, endpoint_context)
85
+
86
+ result = hook[:handler].call(endpoint_context)
87
+ next unless result
88
+
89
+ context_data = fetch_key(result, :context)
90
+ if result.is_a?(Hash) && context_data.is_a?(Hash)
91
+ endpoint_context.merge_context!(context_data)
92
+ next
93
+ end
94
+
95
+ return result
96
+ end
97
+
98
+ nil
99
+ end
100
+
101
+ def run_after_hooks(endpoint_context)
102
+ result = Endpoint::Result.new(
103
+ response: endpoint_context.returned,
104
+ status: endpoint_context.status,
105
+ headers: endpoint_context.response_headers
106
+ )
107
+
108
+ after_hooks.each do |hook|
109
+ next unless hook_matches?(hook, endpoint_context)
110
+
111
+ hook_result = begin
112
+ hook[:handler].call(endpoint_context)
113
+ rescue APIError => error
114
+ error
115
+ end
116
+
117
+ result.headers = endpoint_context.response_headers.dup
118
+
119
+ next unless hook_result
120
+
121
+ normalized = Endpoint::Result.from_value(hook_result, endpoint_context)
122
+ result.response = normalized.response
123
+ result.status = normalized.status
124
+ result.headers = normalized.headers
125
+ endpoint_context.returned = result.response
126
+ endpoint_context.response_headers = result.headers
127
+ end
128
+
129
+ result
130
+ end
131
+
132
+ def normalize_short_circuit(value, endpoint_context)
133
+ Endpoint::Result.from_value(value, endpoint_context)
134
+ rescue APIError => error
135
+ Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
136
+ end
137
+
138
+ def format_result(result, input)
139
+ return result.to_rack_response if result.raw_response?
140
+
141
+ if result.response.is_a?(APIError)
142
+ return error_response(result.response, headers: result.headers) if input[:as_response]
143
+
144
+ raise result.response
145
+ end
146
+
147
+ return result.to_rack_response if input[:as_response]
148
+
149
+ if input[:return_headers]
150
+ output = {
151
+ headers: result.headers,
152
+ response: result.response
153
+ }
154
+ output[:status] = result.status if input[:return_status]
155
+ return output
156
+ end
157
+
158
+ return {response: result.response, status: result.status} if input[:return_status]
159
+
160
+ result.response
161
+ end
162
+
163
+ def error_response(error, headers: {})
164
+ Endpoint::Result.new(
165
+ response: error.to_h,
166
+ status: error.status_code,
167
+ headers: Endpoint::Result.merge_headers(headers, error.headers)
168
+ ).to_rack_response
169
+ end
170
+
171
+ def before_hooks
172
+ hooks = []
173
+ user_before = context.options.hooks&.fetch(:before, nil)
174
+ hooks << {matcher: ->(_ctx) { true }, handler: user_before} if user_before
175
+ hooks.concat(plugin_hooks(:before))
176
+ hooks
177
+ end
178
+
179
+ def after_hooks
180
+ hooks = []
181
+ user_after = context.options.hooks&.fetch(:after, nil)
182
+ hooks << {matcher: ->(_ctx) { true }, handler: user_after} if user_after
183
+ hooks.concat(plugin_hooks(:after))
184
+ hooks
185
+ end
186
+
187
+ def plugin_hooks(type)
188
+ context.options.plugins.flat_map do |plugin|
189
+ hooks = plugin.dig(:hooks, type)
190
+ Array(hooks).map do |hook|
191
+ {
192
+ matcher: hook[:matcher] || ->(_ctx) { true },
193
+ handler: hook[:handler]
194
+ }
195
+ end
196
+ end.compact
197
+ end
198
+
199
+ def hook_matches?(hook, endpoint_context)
200
+ matcher = hook[:matcher] || ->(_ctx) { true }
201
+ matcher.call(endpoint_context)
202
+ end
203
+
204
+ def fetch_key(hash, key)
205
+ return unless hash.is_a?(Hash)
206
+
207
+ hash[key] || hash[key.to_s]
208
+ end
209
+
210
+ def symbolize_keys(value)
211
+ return value unless value.is_a?(Hash)
212
+
213
+ value.each_with_object({}) do |(key, object_value), result|
214
+ result[normalize_key(key)] = object_value.is_a?(Hash) ? symbolize_keys(object_value) : object_value
215
+ end
216
+ end
217
+
218
+ def normalize_key(key)
219
+ key.to_s
220
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
221
+ .tr("-", "_")
222
+ .downcase
223
+ .to_sym
224
+ end
225
+ end
226
+ end