better_auth 0.4.0 → 0.6.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38179f5800613263ca30525a3d878b91c2a5f9057f104b1f24cecbf72a0cc030
4
- data.tar.gz: 9883d339ce2f1ab8f5a618da3708f4ab5bdde69a09fe98b48889b6069351c7bb
3
+ metadata.gz: 3d0edd237bbc32992ae24fec0305f09d55521add17768dceac160198e9a7d190
4
+ data.tar.gz: fb04ec9c038649e8fbf2dc34aecb92b5959342eedc598daefce06e3ae7f6131a
5
5
  SHA512:
6
- metadata.gz: b0bdd97ab9d677df601b0eed5d387a09543743aee41d0243f7ed3981935c3391f3ae10dfc110fc41312a5a4b188f29581563f2c99154a8808ed3158cb47663ad
7
- data.tar.gz: 6b9b76c6969e601e01506a2cd91a28abf13eeccf0446023fad7c58e8c95476f6110b7ac6f3979fc18705687589cff74748f839c685a8ccb791392d0d2e729c15
6
+ metadata.gz: 593471f2dee235f6f75d479c61f24a49a23e6d8fec8db00fe8fa5d20afadf179bb1cb6d62b07a668b6c1246b60c934626c5906e3ea965912806afd00bbda1ee4
7
+ data.tar.gz: 1742e7eb46663909392cf010e3ada3aa675f68cdcc5160c0019e561407c467c4831f7c19a22bf9a660ee0527db7131f768fad99528dbefb40d8be51ae6b5fc39
data/CHANGELOG.md CHANGED
@@ -7,6 +7,8 @@ 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
+
10
12
  ## [0.4.0] - 2026-04-30
11
13
 
12
14
  ### 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)
@@ -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)
@@ -303,8 +364,7 @@ module BetterAuth
303
364
  output[field] = coerce_output_value(fetch_row(row, column), attributes) if row_key?(row, column)
304
365
  end
305
366
 
306
- join&.each_key do |join_model|
307
- join_model = join_model.to_s
367
+ normalized_join(model, join).each_key do |join_model|
308
368
  record[join_model] = normalize_joined_record(join_model, row)
309
369
  end
310
370
 
@@ -320,16 +380,34 @@ module BetterAuth
320
380
  end
321
381
 
322
382
  def collection_join?(model, join)
323
- 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
324
386
  end
325
387
 
326
- def aggregate_collection_joins(_model, records, _join)
388
+ def aggregate_collection_joins(model, records, join)
389
+ join_config = normalized_join(model, join)
327
390
  grouped = {}
328
391
  records.each do |record|
329
392
  key = record.fetch("id")
330
- grouped[key] ||= record.merge("account" => [])
331
- account = record["account"]
332
- 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
333
411
  end
334
412
  grouped.values
335
413
  end
@@ -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,16 +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
318
  normalized_key = normalize_key(key)
215
- result[normalized_key] = if normalized_key == :metadata
216
- object_value
217
- else
218
- object_value.is_a?(Hash) ? symbolize_keys(object_value) : object_value
219
- end
319
+ result[normalized_key] = object_value
220
320
  end
221
321
  end
222
322
 
@@ -31,10 +31,11 @@ module BetterAuth
31
31
  }.freeze
32
32
 
33
33
  attr_reader :app_name,
34
- :base_url,
34
+ :base_url_config,
35
35
  :base_path,
36
36
  :context_base_url,
37
37
  :secret,
38
+ :secret_config,
38
39
  :database,
39
40
  :plugins,
40
41
  :trusted_origins,
@@ -73,8 +74,19 @@ module BetterAuth
73
74
  @hooks = options[:hooks]
74
75
  @on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
75
76
  @social_providers = symbolize_keys(options[:social_providers] || {})
76
- @trusted_origins_callback = options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
77
- @secret = resolve_secret(options)
77
+ @trusted_origins_callbacks = []
78
+ @trusted_origins_callbacks << options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
79
+ @trusted_origins_callback = combined_trusted_origins_callback
80
+ legacy_secret = resolve_secret(options, allow_test_default: false)
81
+ secrets = options.key?(:secrets) ? options[:secrets] : SecretConfig.parse_env(ENV["BETTER_AUTH_SECRETS"])
82
+ if secrets
83
+ @secret_config = SecretConfig.build(secrets, legacy_secret, logger: logger)
84
+ @secret = @secret_config.current_secret
85
+ else
86
+ @secret = legacy_secret || (test_environment? ? DEFAULT_SECRET : nil)
87
+ @secret_config = @secret
88
+ end
89
+ @base_url_config = options[:base_url]
78
90
  @base_url, @context_base_url = normalize_base_url(options[:base_url])
79
91
  @session = normalize_session(options[:session])
80
92
  @account = normalize_account(options[:account])
@@ -96,16 +108,33 @@ module BetterAuth
96
108
  end
97
109
  end
98
110
 
111
+ def base_url
112
+ Thread.current[base_url_runtime_key] || @base_url
113
+ end
114
+
115
+ def set_runtime_base_url(value)
116
+ Thread.current[base_url_runtime_key] = value
117
+ end
118
+
119
+ def clear_runtime_base_url!
120
+ Thread.current[base_url_runtime_key] = nil
121
+ end
122
+
99
123
  def production?
100
124
  production_environment?
101
125
  end
102
126
 
127
+ def dynamic_base_url?
128
+ URLHelpers.dynamic_config?(base_url_config)
129
+ end
130
+
103
131
  def to_h
104
132
  {
105
133
  app_name: app_name,
106
134
  base_url: base_url,
107
135
  base_path: base_path,
108
136
  secret: secret,
137
+ secret_config: secret_config,
109
138
  database: database,
110
139
  plugins: plugins,
111
140
  trusted_origins: trusted_origins,
@@ -134,6 +163,11 @@ module BetterAuth
134
163
  next unless respond_to?(key)
135
164
  next if key == :database_hooks
136
165
 
166
+ if key == :trusted_origins
167
+ merge_trusted_origins_default(value)
168
+ next
169
+ end
170
+
137
171
  instance_variable_set("@#{key}", merge_default_value([key], public_send(key), value))
138
172
  end
139
173
  end
@@ -146,7 +180,12 @@ module BetterAuth
146
180
  return false unless uri
147
181
 
148
182
  if pattern.include?("*") || pattern.include?("?")
149
- return wildcard_match?(pattern, origin_for(uri) || url) if pattern.include?("://")
183
+ if pattern.include?("://")
184
+ origin = origin_for(uri)
185
+ return true if origin && wildcard_match?(pattern, origin)
186
+
187
+ return wildcard_match?(pattern, url)
188
+ end
150
189
 
151
190
  return wildcard_match?(pattern, uri.host.to_s)
152
191
  end
@@ -191,6 +230,15 @@ module BetterAuth
191
230
  configured = value || env_base_url
192
231
  return ["", ""] unless configured && !configured.empty?
193
232
 
233
+ if URLHelpers.dynamic_config?(configured)
234
+ validate_dynamic_base_url!(configured)
235
+ resolved = URLHelpers.resolve_base_url(configured, base_path)
236
+ return ["", ""] unless resolved
237
+
238
+ uri = URI.parse(resolved)
239
+ return [self.class.origin_for(uri), resolved.sub(%r{/+\z}, "")]
240
+ end
241
+
194
242
  with_path = append_base_path(configured.to_s)
195
243
  uri = URI.parse(with_path)
196
244
  validate_http_url!(uri, configured)
@@ -199,6 +247,11 @@ module BetterAuth
199
247
  raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
200
248
  end
201
249
 
250
+ def validate_dynamic_base_url!(value)
251
+ allowed_hosts = value[:allowed_hosts] || value["allowed_hosts"] || value[:allowedHosts] || value["allowedHosts"]
252
+ raise Error, "baseURL.allowedHosts cannot be empty" if allowed_hosts.respond_to?(:empty?) && allowed_hosts.empty?
253
+ end
254
+
202
255
  def normalize_base_path(value)
203
256
  return "" if value.nil? || value == "" || value == "/"
204
257
 
@@ -235,8 +288,9 @@ module BetterAuth
235
288
  ].find { |value| value && !value.empty? }
236
289
  end
237
290
 
238
- def resolve_secret(options)
239
- options[:secret] || ENV["BETTER_AUTH_SECRET"] || ENV["AUTH_SECRET"] || (test_environment? ? DEFAULT_SECRET : nil)
291
+ def resolve_secret(options, allow_test_default: true)
292
+ [options[:secret], ENV["BETTER_AUTH_SECRET"], ENV["AUTH_SECRET"]].find { |value| value && !value.empty? } ||
293
+ ((allow_test_default && test_environment?) ? DEFAULT_SECRET : nil)
240
294
  end
241
295
 
242
296
  def validate_secret
@@ -244,6 +298,10 @@ module BetterAuth
244
298
  raise Error, "BETTER_AUTH_SECRET is missing. Set it in your environment or pass `secret` to BetterAuth.auth(secret: ...)."
245
299
  end
246
300
 
301
+ if production_environment? && secret == DEFAULT_SECRET
302
+ raise Error, "You are using the default secret. Please set `BETTER_AUTH_SECRET` in your environment variables or pass `secret` in your auth config."
303
+ end
304
+
247
305
  return if test_environment? && secret == DEFAULT_SECRET
248
306
 
249
307
  warn("[better-auth] Warning: your BETTER_AUTH_SECRET should be at least 32 characters long for adequate security.") if secret.length < 32
@@ -263,7 +321,8 @@ module BetterAuth
263
321
  session = deep_merge(DEFAULT_SESSION, configured)
264
322
 
265
323
  if database.nil?
266
- session = deep_merge(session, DEFAULT_STATELESS_SESSION)
324
+ session = deep_merge(session, deep_dup(DEFAULT_STATELESS_SESSION))
325
+ session[:cookie_cache][:max_age] ||= session[:expires_in]
267
326
  else
268
327
  session[:cookie_cache] = cookie_cache unless cookie_cache.empty?
269
328
  end
@@ -316,11 +375,38 @@ module BetterAuth
316
375
  def normalize_trusted_origins(value)
317
376
  origins = []
318
377
  origins << base_url unless base_url.nil? || base_url.empty?
378
+ origins.concat(dynamic_base_url_trusted_origins)
319
379
  origins.concat(Array(value).compact) unless value.respond_to?(:call)
320
380
  origins.concat(env_trusted_origins)
321
381
  origins.map(&:to_s).reject(&:empty?).uniq
322
382
  end
323
383
 
384
+ def dynamic_base_url_trusted_origins
385
+ return [] unless URLHelpers.dynamic_config?(base_url_config)
386
+
387
+ protocol = base_url_config[:protocol] || base_url_config["protocol"] || "https"
388
+ allowed_hosts = base_url_config[:allowed_hosts] || base_url_config["allowed_hosts"] || base_url_config[:allowedHosts] || base_url_config["allowedHosts"] || []
389
+ Array(allowed_hosts).map do |host|
390
+ host = host.to_s
391
+ host.match?(%r{\Ahttps?://}i) ? host : "#{protocol}://#{host}"
392
+ end
393
+ end
394
+
395
+ def merge_trusted_origins_default(value)
396
+ if value.respond_to?(:call)
397
+ @trusted_origins_callbacks << value
398
+ @trusted_origins_callback = combined_trusted_origins_callback
399
+ else
400
+ @trusted_origins = (trusted_origins + Array(value).compact.map(&:to_s).reject(&:empty?)).uniq
401
+ end
402
+ end
403
+
404
+ def combined_trusted_origins_callback
405
+ return nil if @trusted_origins_callbacks.empty?
406
+
407
+ ->(request) { @trusted_origins_callbacks.flat_map { |callback| Array(callback.call(request)) }.compact }
408
+ end
409
+
324
410
  def env_trusted_origins
325
411
  ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip).reject(&:empty?)
326
412
  end
@@ -388,6 +474,10 @@ module BetterAuth
388
474
  end
389
475
  end
390
476
 
477
+ def base_url_runtime_key
478
+ :"better_auth_configuration_base_url_#{object_id}"
479
+ end
480
+
391
481
  def test_environment?
392
482
  ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
393
483
  end