better_auth 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df93cdae06059d52fa2c3d35c5b173aba9025d5ac46bda0b93a7a8abc5045f40
4
- data.tar.gz: adec802dade2610da15329266c91dcd46aecb8642165c29e364ce7db2829d217
3
+ metadata.gz: cd7630e5e1e1775f9db5545b1f81cbe0fed67da4e38ac59e0aa92ec3a896da3a
4
+ data.tar.gz: 24bfbe49981f12d00533f0db6acc9e899bdeb3edea1145f2e6c38d5cc9e8493d
5
5
  SHA512:
6
- metadata.gz: f46ac8a5cf79a859a417e2cbe60f000c290d014975fb3ce910c49b6c1cd455b2123e436a42a323dd530b38096a4ebc18a1f206c97f0ef6624378c9eb051d37e3
7
- data.tar.gz: '0469ddcdcad97a7b1b58e3ddc0ef8d54c7469a1e0411546367f829291ab5664fc0b4685d460d8d328fe6ef67543908060d33eb961a638121930154ca31a4cfaf'
6
+ metadata.gz: e168ce722c56f26dddc5e0e4eb34626ae6b4523aae547768970bde07b68f56ba31db302a2e1ea8abf0e4d1e5a5ad98018a7f0129174cc3ef09486294c7bfa28a
7
+ data.tar.gz: b7b9e1bbbab49acc51d876dadeebf067976658782af41a1b82e7c78d3fc2b2d15106d0e90ac7699a380e8b7f4b9211ad105bb388d8f95db17f8624b537e29d7b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ - Modernized the MCP plugin to use OAuth Provider-style client, token, metadata, and protected-resource behavior while keeping legacy MCP routes as aliases.
11
+
12
+ ## [0.4.0] - 2026-04-30
13
+
14
+ ### Added
15
+
16
+ - Added upstream-parity helpers for async execution, host resolution, instrumentation, request state, URL handling, OAuth2, deprecation warnings, and expanded route behavior.
17
+ - Added two-factor, OAuth protocol, social route, organization, admin, adapter, schema, and session parity coverage.
18
+
19
+ ### Changed
20
+
21
+ - Aligned core auth, email OTP, generic OAuth, organization, two-factor, OAuth protocol, adapter, router, rate-limiter, logger, and middleware behavior more closely with upstream Better Auth.
22
+
23
+ ### Fixed
24
+
25
+ - Fixed upstream parity gaps in organization handling, generic OAuth user info, email OTP sign-up, database schema behavior, and route/session edge cases.
26
+
10
27
  ## [0.3.0] - 2026-04-29
11
28
 
12
29
  ### Added
data/README.md CHANGED
@@ -66,6 +66,30 @@ auth = BetterAuth.auth(
66
66
  )
67
67
  ```
68
68
 
69
+ ### Secret Rotation
70
+
71
+ Better Auth Ruby supports upstream-style non-destructive rotation for encrypted data through versioned secrets. The first entry is used for new encrypted payloads; older entries remain available for decrypting existing data.
72
+
73
+ ```ruby
74
+ auth = BetterAuth.auth(
75
+ secret: ENV["BETTER_AUTH_SECRET"], # legacy fallback for older encrypted data
76
+ secrets: [
77
+ { version: 2, value: ENV.fetch("BETTER_AUTH_SECRET_V2") },
78
+ { version: 1, value: ENV.fetch("BETTER_AUTH_SECRET_V1") }
79
+ ],
80
+ database: :memory
81
+ )
82
+ ```
83
+
84
+ You can also configure the same list via `BETTER_AUTH_SECRETS`:
85
+
86
+ ```bash
87
+ BETTER_AUTH_SECRETS="2:new-secret-base64,1:old-secret-base64"
88
+ BETTER_AUTH_SECRET="legacy-secret-for-pre-rotation-data"
89
+ ```
90
+
91
+ Signed cookies and HMAC/JWT signatures continue to use the current `secret`, matching upstream Better Auth behavior.
92
+
69
93
  ### Password Hashing
70
94
 
71
95
  Better Auth Ruby uses upstream-compatible `scrypt` password hashes by default through Ruby's `OpenSSL::KDF.scrypt`, so no extra password-hashing gem is required for the default setup.
@@ -15,18 +15,18 @@ module BetterAuth
15
15
  @hooks = DatabaseHooks.new(adapter, options)
16
16
  end
17
17
 
18
- def create_oauth_user(user, account)
18
+ def create_oauth_user(user, account, context: nil)
19
19
  adapter.transaction do
20
- created_user = create_user(user)
20
+ created_user = create_user(user, context: context)
21
21
  created_account = create_account(stringify_keys(account).merge("userId" => created_user["id"]))
22
22
  {user: created_user, account: created_account}
23
23
  end
24
24
  end
25
25
 
26
- def create_user(user)
27
- data = timestamps.merge(stringify_keys(user))
26
+ def create_user(user = nil, context: nil, **keywords)
27
+ data = timestamps.merge(stringify_keys((user || {}).merge(keywords)))
28
28
  data["email"] = data["email"].to_s.downcase if data["email"]
29
- hooks.create(data, "user")
29
+ hooks.create(data, "user", context: context)
30
30
  end
31
31
 
32
32
  def create_account(account)
@@ -59,9 +59,12 @@ module BetterAuth
59
59
  end
60
60
 
61
61
  def delete_user(user_id)
62
- delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
62
+ deleted = hooks.delete([{field: "id", value: user_id}], "user")
63
+ return false if deleted == false
64
+
63
65
  hooks.delete_many([{field: "userId", value: user_id}], "account")
64
- hooks.delete([{field: "id", value: user_id}], "user")
66
+ delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
67
+ deleted
65
68
  end
66
69
 
67
70
  def create_session(user_id, dont_remember_me = false, override = nil, override_all = false, context = nil)
@@ -156,33 +156,79 @@ module BetterAuth
156
156
  value = fetch_key(clause, :value)
157
157
  operator = (fetch_key(clause, :operator) || "eq").to_s
158
158
  current = record[field]
159
+ comparable = coerce_where_value(record, field, value, operator)
159
160
 
160
161
  case operator
161
162
  when "in"
162
- Array(value).include?(current)
163
+ Array(comparable).include?(current)
163
164
  when "not_in"
164
- !Array(value).include?(current)
165
+ !Array(comparable).include?(current)
165
166
  when "contains"
166
- current.to_s.include?(value.to_s)
167
+ current.to_s.include?(comparable.to_s)
167
168
  when "starts_with"
168
- current.to_s.start_with?(value.to_s)
169
+ current.to_s.start_with?(comparable.to_s)
169
170
  when "ends_with"
170
- current.to_s.end_with?(value.to_s)
171
+ current.to_s.end_with?(comparable.to_s)
171
172
  when "ne"
172
- current != value
173
+ current != comparable
173
174
  when "gt"
174
- !value.nil? && current > value
175
+ !comparable.nil? && current > comparable
175
176
  when "gte"
176
- !value.nil? && current >= value
177
+ !comparable.nil? && current >= comparable
177
178
  when "lt"
178
- !value.nil? && current < value
179
+ !comparable.nil? && current < comparable
179
180
  when "lte"
180
- !value.nil? && current <= value
181
+ !comparable.nil? && current <= comparable
181
182
  else
182
- current == value
183
+ current == comparable
183
184
  end
184
185
  end
185
186
 
187
+ def coerce_where_value(record, field, value, operator)
188
+ attributes = schema_for_record_field(record, field)
189
+ return value unless attributes
190
+ return Array(value).map { |entry| coerce_scalar_where_value(entry, attributes) } if %w[in not_in].include?(operator)
191
+
192
+ coerce_scalar_where_value(value, attributes)
193
+ end
194
+
195
+ def schema_for_record_field(record, field)
196
+ db.each_key do |model|
197
+ fields = Schema.auth_tables(options)[model]&.fetch(:fields, nil)
198
+ next unless fields&.key?(field)
199
+ return fields[field] if table_for(model).include?(record)
200
+ end
201
+ nil
202
+ end
203
+
204
+ def coerce_scalar_where_value(value, attributes)
205
+ return value if value.nil?
206
+
207
+ case attributes[:type]
208
+ when "boolean"
209
+ return false if value == false || value == 0 || value.to_s.downcase == "false" || value.to_s == "0"
210
+ return true if value == true || value == 1 || value.to_s.downcase == "true" || value.to_s == "1"
211
+ when "number"
212
+ return coerce_number(value)
213
+ when "date"
214
+ return Time.parse(value) if value.is_a?(String)
215
+ when "number[]"
216
+ return Array(value).map { |entry| coerce_number(entry) }
217
+ when "string[]"
218
+ return Array(value).map(&:to_s)
219
+ end
220
+
221
+ value
222
+ end
223
+
224
+ def coerce_number(value)
225
+ return value unless value.is_a?(String)
226
+ return value.to_i if /\A-?\d+\z/.match?(value)
227
+ return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
228
+
229
+ value
230
+ end
231
+
186
232
  def sort_records(model, records, sort_by)
187
233
  field = Schema.storage_key(fetch_key(sort_by, :field))
188
234
  direction = fetch_key(sort_by, :direction).to_s
@@ -173,8 +173,7 @@ module BetterAuth
173
173
  end
174
174
 
175
175
  def join_select_sql(model, join)
176
- join.flat_map do |join_model, _enabled|
177
- join_model = join_model.to_s
176
+ normalized_join(model, join).flat_map do |join_model, _config|
178
177
  schema_for(join_model).fetch(:fields).map do |field, attributes|
179
178
  column = attributes[:field_name] || physical_name(field)
180
179
  "#{quote(join_model)}.#{quote(column)} AS #{quote("#{join_model}__#{column}")}"
@@ -185,17 +184,79 @@ module BetterAuth
185
184
  def join_sql(model, join)
186
185
  return "" unless join
187
186
 
188
- join.map do |join_model, _enabled|
187
+ normalized_join(model, join).map do |join_model, config|
188
+ local_field = storage_field(model, config.fetch(:from))
189
+ foreign_field = storage_field(join_model, config.fetch(:to))
190
+ " LEFT JOIN #{quote(table_for(join_model))} AS #{quote(join_model)} ON #{quote(join_model)}.#{quote(foreign_field)} = #{quote(table_for(model))}.#{quote(local_field)}"
191
+ end.join
192
+ end
193
+
194
+ def normalized_join(model, join)
195
+ return {} unless join
196
+
197
+ join.each_with_object({}) do |(join_model, config), result|
189
198
  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
- ""
199
+ result[join_model] = normalize_join_config(model.to_s, join_model, config)
200
+ end
201
+ end
202
+
203
+ def normalize_join_config(model, join_model, config)
204
+ if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
205
+ on = config[:on] || config["on"]
206
+ from = storage_key(fetch_key(on, :from))
207
+ to = storage_key(fetch_key(on, :to))
208
+ relation = config[:relation] || config["relation"]
209
+ limit = config[:limit] || config["limit"]
210
+ return {from: from, to: to, relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
211
+ end
212
+
213
+ inferred = inferred_join_config(model, join_model)
214
+ if config.is_a?(Hash)
215
+ relation = config[:relation] || config["relation"]
216
+ limit = config[:limit] || config["limit"]
217
+ inferred = inferred.merge(relation: relation) if relation
218
+ inferred = inferred.merge(limit: limit) if limit
219
+ end
220
+ inferred
221
+ end
222
+
223
+ def inferred_join_config(model, join_model)
224
+ foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
225
+ reference_model_matches?(attributes, model)
226
+ end
227
+ forward_join = true
228
+
229
+ if foreign_keys.empty?
230
+ foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
231
+ reference_model_matches?(attributes, join_model)
197
232
  end
198
- end.join
233
+ forward_join = false
234
+ end
235
+
236
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
237
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported." if foreign_keys.length > 1
238
+
239
+ foreign_key, attributes = foreign_keys.first
240
+ reference = attributes.fetch(:references)
241
+ if forward_join
242
+ unique = attributes[:unique] == true
243
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
244
+ else
245
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
246
+ end
247
+ end
248
+
249
+ def reference_model_matches?(attributes, model)
250
+ reference = attributes[:references]
251
+ return false unless reference
252
+
253
+ reference_model = reference[:model] || reference["model"]
254
+ reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
255
+ end
256
+
257
+ def unique_join_field?(model, field)
258
+ field = storage_key(field)
259
+ field == "id" || schema_for(model).fetch(:fields).dig(field, :unique) == true
199
260
  end
200
261
 
201
262
  def build_where(model, where, params)
@@ -204,10 +265,11 @@ module BetterAuth
204
265
  column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
205
266
  operator = (fetch_key(clause, :operator) || "eq").to_s
206
267
  value = fetch_key(clause, :value)
268
+ attributes = schema_for(model).fetch(:fields).fetch(field)
207
269
 
208
270
  expression = case operator
209
271
  when "in", "not_in"
210
- values = Array(value)
272
+ values = Array(value).map { |entry| coerce_where_value(entry, attributes) }
211
273
  placeholders = values.map do |entry|
212
274
  params << entry
213
275
  placeholder(params.length)
@@ -223,7 +285,7 @@ module BetterAuth
223
285
  params << pattern
224
286
  "#{column} LIKE #{placeholder(params.length)}"
225
287
  else
226
- params << value
288
+ params << coerce_where_value(value, attributes)
227
289
  "#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
228
290
  end
229
291
 
@@ -302,8 +364,7 @@ module BetterAuth
302
364
  output[field] = coerce_output_value(fetch_row(row, column), attributes) if row_key?(row, column)
303
365
  end
304
366
 
305
- join&.each_key do |join_model|
306
- join_model = join_model.to_s
367
+ normalized_join(model, join).each_key do |join_model|
307
368
  record[join_model] = normalize_joined_record(join_model, row)
308
369
  end
309
370
 
@@ -319,16 +380,34 @@ module BetterAuth
319
380
  end
320
381
 
321
382
  def collection_join?(model, join)
322
- model == "user" && join&.keys&.any? { |join_model| join_model.to_s == "account" }
383
+ normalized_join(model, join).any? do |_join_model, config|
384
+ config[:relation] != "one-to-one" && config[:unique] != true
385
+ end
323
386
  end
324
387
 
325
- def aggregate_collection_joins(_model, records, _join)
388
+ def aggregate_collection_joins(model, records, join)
389
+ join_config = normalized_join(model, join)
326
390
  grouped = {}
327
391
  records.each do |record|
328
392
  key = record.fetch("id")
329
- grouped[key] ||= record.merge("account" => [])
330
- account = record["account"]
331
- grouped[key]["account"] << account if account&.values&.any?
393
+ grouped[key] ||= begin
394
+ base = record.reject { |field, _value| join_config.key?(field) }
395
+ join_defaults = join_config.each_with_object({}) do |(join_model, config), defaults|
396
+ defaults[join_model] = (config[:relation] == "one-to-one" || config[:unique] == true) ? nil : []
397
+ end
398
+ base.merge(join_defaults)
399
+ end
400
+
401
+ join_config.each do |join_model, config|
402
+ joined = record[join_model]
403
+ next unless joined&.values&.any?
404
+
405
+ if config[:relation] == "one-to-one" || config[:unique] == true
406
+ grouped[key][join_model] = joined
407
+ else
408
+ grouped[key][join_model] << joined
409
+ end
410
+ end
332
411
  end
333
412
  grouped.values
334
413
  end
@@ -385,6 +464,22 @@ module BetterAuth
385
464
  value
386
465
  end
387
466
 
467
+ def coerce_where_value(value, attributes)
468
+ return value if value.nil?
469
+
470
+ case attributes[:type]
471
+ when "boolean"
472
+ return coerce_value(false, attributes) if value == false || value == 0 || value.to_s.downcase == "false" || value.to_s == "0"
473
+ return coerce_value(true, attributes) if value == true || value == 1 || value.to_s.downcase == "true" || value.to_s == "1"
474
+ when "number"
475
+ return coerce_number(value)
476
+ when "date"
477
+ return Time.parse(value) if value.is_a?(String)
478
+ end
479
+
480
+ coerce_value(value, attributes)
481
+ end
482
+
388
483
  def coerce_output_value(value, attributes)
389
484
  return value if value.nil?
390
485
  return coerce_boolean(value) if attributes[:type] == "boolean"
@@ -412,6 +507,14 @@ module BetterAuth
412
507
  value
413
508
  end
414
509
 
510
+ def coerce_number(value)
511
+ return value unless value.is_a?(String)
512
+ return value.to_i if /\A-?\d+\z/.match?(value)
513
+ return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
514
+
515
+ value
516
+ end
517
+
415
518
  def stringify_keys(data)
416
519
  data.each_with_object({}) do |(key, value), result|
417
520
  result[storage_key(key)] = value
@@ -14,18 +14,34 @@ module BetterAuth
14
14
  context.reset_runtime! if context.respond_to?(:reset_runtime!)
15
15
  endpoint = endpoints.fetch(key.to_sym)
16
16
  input = symbolize_keys(input || {})
17
+ request = input[:request]
18
+ headers = request_headers(request).merge(normalize_headers_hash(input[:headers] || {}))
19
+ begin
20
+ prepare_context_for_call!(request, headers)
21
+ rescue APIError => error
22
+ result = Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
23
+ return format_result(result, input)
24
+ rescue Error => error
25
+ api_error = APIError.new("INTERNAL_SERVER_ERROR", message: error.message)
26
+ result = Endpoint::Result.new(response: api_error, status: api_error.status_code, headers: api_error.headers)
27
+ return format_result(result, input)
28
+ end
29
+ input[:as_response] = true if request_like?(request) && !input.key?(:as_response)
17
30
  endpoint_context = Endpoint::Context.new(
18
31
  path: endpoint.path,
19
- method: input[:method] || Array(endpoint.methods).first,
32
+ method: input[:method] || request_method(request) || Array(endpoint.methods).first,
20
33
  query: input[:query] || {},
21
34
  body: input[:body] || {},
22
35
  params: input[:params] || {},
23
- headers: input[:headers] || {},
24
- context: context
36
+ headers: headers,
37
+ context: context,
38
+ request: request_like?(request) ? request : nil
25
39
  )
26
40
 
27
41
  result = run_endpoint_with_hooks(endpoint, endpoint_context)
28
42
  format_result(result, input)
43
+ ensure
44
+ context.clear_runtime! if context.respond_to?(:clear_runtime!)
29
45
  end
30
46
 
31
47
  def execute(endpoint, endpoint_context)
@@ -51,6 +67,34 @@ module BetterAuth
51
67
  .to_sym
52
68
  end
53
69
 
70
+ def prepare_context_for_call!(request, headers)
71
+ return unless context.respond_to?(:prepare_for_api_call!)
72
+
73
+ source = direct_call_source(request, headers)
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)
82
+ end
83
+
84
+ def direct_call_source(request, headers)
85
+ return request if request_like?(request)
86
+ return headers if headers.key?("host") || headers.key?("x-forwarded-host")
87
+
88
+ nil
89
+ end
90
+
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
+
54
98
  def run_endpoint_with_hooks(endpoint, endpoint_context)
55
99
  before = run_before_hooks(endpoint_context)
56
100
  return normalize_short_circuit(before, endpoint_context) if before
@@ -136,15 +180,16 @@ module BetterAuth
136
180
  end
137
181
 
138
182
  def format_result(result, input)
139
- return result.to_rack_response if result.raw_response?
183
+ return result.to_response if result.raw_response?
184
+ as_response = input[:as_response] || request_like?(input[:request])
140
185
 
141
186
  if result.response.is_a?(APIError)
142
- return error_response(result.response, headers: result.headers) if input[:as_response]
187
+ return error_response(result.response, headers: result.headers) if as_response
143
188
 
144
- raise result.response
189
+ raise error_with_headers(result.response, result.headers)
145
190
  end
146
191
 
147
- return result.to_rack_response if input[:as_response]
192
+ return result.to_response if as_response
148
193
 
149
194
  if input[:return_headers]
150
195
  output = {
@@ -165,7 +210,17 @@ module BetterAuth
165
210
  response: error.to_h,
166
211
  status: error.status_code,
167
212
  headers: Endpoint::Result.merge_headers(headers, error.headers)
168
- ).to_rack_response
213
+ ).to_response
214
+ end
215
+
216
+ def error_with_headers(error, headers)
217
+ APIError.new(
218
+ error.status,
219
+ message: error.message,
220
+ headers: Endpoint::Result.merge_headers(headers, error.headers),
221
+ code: error.code,
222
+ body: error.body
223
+ )
169
224
  end
170
225
 
171
226
  def before_hooks
@@ -207,11 +262,61 @@ module BetterAuth
207
262
  hash[key] || hash[key.to_s]
208
263
  end
209
264
 
265
+ def request_like?(value)
266
+ return false unless value
267
+
268
+ value.respond_to?(:request_method) || request_method(value) || !request_headers(value).empty?
269
+ end
270
+
271
+ def request_method(request)
272
+ return unless request
273
+ return request.request_method if request.respond_to?(:request_method)
274
+
275
+ request.public_send(:method)
276
+ rescue ArgumentError, NoMethodError
277
+ nil
278
+ end
279
+
280
+ def request_headers(request)
281
+ return {} unless request
282
+
283
+ if request.respond_to?(:env)
284
+ return headers_from_env(request.env)
285
+ end
286
+
287
+ return normalize_headers_hash(request.headers) if request.respond_to?(:headers)
288
+
289
+ {}
290
+ end
291
+
292
+ def headers_from_env(env)
293
+ env.each_with_object({}) do |(key, value), headers|
294
+ case key
295
+ when "CONTENT_TYPE"
296
+ headers["content-type"] = value if value
297
+ when "CONTENT_LENGTH"
298
+ headers["content-length"] = value if value
299
+ else
300
+ next unless key.start_with?("HTTP_")
301
+
302
+ header = key.delete_prefix("HTTP_").downcase.tr("_", "-")
303
+ headers[header] = value
304
+ end
305
+ end
306
+ end
307
+
308
+ def normalize_headers_hash(headers)
309
+ headers.each_with_object({}) do |(key, value), result|
310
+ result[key.to_s.downcase.tr("_", "-")] = value
311
+ end
312
+ end
313
+
210
314
  def symbolize_keys(value)
211
315
  return value unless value.is_a?(Hash)
212
316
 
213
317
  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
318
+ normalized_key = normalize_key(key)
319
+ result[normalized_key] = object_value
215
320
  end
216
321
  end
217
322
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Async
5
+ module_function
6
+
7
+ def map_concurrent(items, concurrency:, &mapper)
8
+ list = items.to_a
9
+ return [] if list.empty?
10
+
11
+ width = normalized_concurrency(concurrency, list.length)
12
+ results = Array.new(list.length)
13
+ next_index = 0
14
+ first_error = nil
15
+ mutex = Mutex.new
16
+ status = Queue.new
17
+
18
+ workers = Array.new(width) do
19
+ Thread.new do
20
+ loop do
21
+ index = mutex.synchronize do
22
+ break if first_error || next_index >= list.length
23
+
24
+ current = next_index
25
+ next_index += 1
26
+ current
27
+ end
28
+ break unless index
29
+
30
+ begin
31
+ results[index] = mapper.call(list[index], index)
32
+ rescue => error
33
+ mutex.synchronize { first_error ||= error }
34
+ status << [:error, error]
35
+ break
36
+ end
37
+ end
38
+ ensure
39
+ status << [:done, nil]
40
+ end
41
+ end
42
+
43
+ done = 0
44
+ while done < workers.length
45
+ type, error = status.pop
46
+ if type == :error
47
+ workers.each { |worker| worker.kill if worker.alive? }
48
+ workers.each(&:join)
49
+ raise error
50
+ end
51
+
52
+ done += 1
53
+ end
54
+
55
+ raise first_error if first_error
56
+
57
+ results
58
+ end
59
+
60
+ def normalized_concurrency(concurrency, item_count)
61
+ raw = begin
62
+ Float(concurrency).floor
63
+ rescue ArgumentError, TypeError
64
+ 1
65
+ end
66
+ raw = 1 if raw < 1
67
+ [raw, item_count].min
68
+ end
69
+ end
70
+ end