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
@@ -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
@@ -5,21 +5,16 @@ require "uri"
5
5
  module BetterAuth
6
6
  class Context
7
7
  attr_reader :app_name,
8
- :base_url,
9
8
  :version,
10
9
  :options,
11
10
  :social_providers,
12
- :cookies,
13
- :auth_cookies,
14
11
  :adapter,
15
12
  :internal_adapter,
16
13
  :logger,
17
14
  :session_config,
18
15
  :rate_limit_config,
19
- :trusted_origins,
20
16
  :secret,
21
- :current_session,
22
- :new_session
17
+ :secret_config
23
18
 
24
19
  def initialize(configuration)
25
20
  @app_name = configuration.app_name
@@ -36,6 +31,7 @@ module BetterAuth
36
31
  @rate_limit_config = configuration.rate_limit
37
32
  @trusted_origins = configuration.trusted_origins
38
33
  @secret = configuration.secret
34
+ @secret_config = configuration.secret_config
39
35
  @current_session = nil
40
36
  @new_session = nil
41
37
  end
@@ -46,12 +42,36 @@ module BetterAuth
46
42
  end
47
43
  end
48
44
 
45
+ def base_url
46
+ runtime_fetch(:base_url, @base_url)
47
+ end
48
+
49
+ def trusted_origins
50
+ runtime_fetch(:trusted_origins, @trusted_origins)
51
+ end
52
+
53
+ def auth_cookies
54
+ runtime_fetch(:auth_cookies, @auth_cookies)
55
+ end
56
+
57
+ def cookies
58
+ runtime_fetch(:cookies, @cookies)
59
+ end
60
+
61
+ def current_session
62
+ runtime_fetch(:current_session, @current_session)
63
+ end
64
+
65
+ def new_session
66
+ runtime_fetch(:new_session, @new_session)
67
+ end
68
+
49
69
  def set_new_session(session)
50
- @new_session = session
70
+ runtime_store(:new_session, session) || @new_session = session
51
71
  end
52
72
 
53
73
  def set_current_session(session)
54
- @current_session = session
74
+ runtime_store(:current_session, session) || @current_session = session
55
75
  end
56
76
 
57
77
  def run_in_background(task)
@@ -63,6 +83,31 @@ module BetterAuth
63
83
  end
64
84
  end
65
85
 
86
+ def password
87
+ config = {
88
+ min_password_length: options.email_and_password[:min_password_length],
89
+ max_password_length: options.email_and_password[:max_password_length]
90
+ }
91
+ password_config = options.email_and_password[:password] || {}
92
+
93
+ {
94
+ config: config,
95
+ hash: ->(value) { Password.hash(value, hasher: password_config[:hash], algorithm: options.password_hasher) },
96
+ verify: lambda do |password:, hash:|
97
+ Password.verify(
98
+ password: password,
99
+ hash: hash,
100
+ verifier: password_config[:verify],
101
+ algorithm: options.password_hasher
102
+ )
103
+ end,
104
+ check_password: lambda do |value|
105
+ length = value.to_s.length
106
+ length.between?(config[:min_password_length].to_i, config[:max_password_length].to_i)
107
+ end
108
+ }
109
+ end
110
+
66
111
  def create_auth_cookie(cookie_name, override_attributes = {})
67
112
  Cookies.create_cookie(options, cookie_name.to_s, override_attributes)
68
113
  end
@@ -87,6 +132,7 @@ module BetterAuth
87
132
  @rate_limit_config = options.rate_limit
88
133
  @trusted_origins = options.trusted_origins
89
134
  @secret = options.secret
135
+ @secret_config = options.secret_config
90
136
  end
91
137
 
92
138
  def method_missing(name, *arguments, &block)
@@ -101,17 +147,52 @@ module BetterAuth
101
147
  end
102
148
 
103
149
  def prepare_for_request!(request)
104
- @current_session = nil
105
- @new_session = nil
106
- @base_url = inferred_base_url(request) if options.base_url.to_s.empty?
107
- @trusted_origins = current_trusted_origins(request)
150
+ runtime = request_runtime
151
+ runtime[:current_session] = nil
152
+ runtime[:new_session] = nil
153
+ if options.dynamic_base_url?
154
+ runtime[:base_url] = resolved_dynamic_base_url(request)
155
+ refresh_cookies!
156
+ elsif options.base_url.to_s.empty?
157
+ runtime[:base_url] = inferred_base_url(request)
158
+ end
159
+ runtime[:trusted_origins] = current_trusted_origins(request)
160
+ end
161
+
162
+ def prepare_for_api_call!(source)
163
+ runtime = request_runtime
164
+ runtime[:current_session] = nil
165
+ runtime[:new_session] = nil
166
+ if options.dynamic_base_url?
167
+ runtime[:base_url] = resolved_dynamic_base_url(source)
168
+ refresh_cookies!
169
+ end
170
+ runtime[:trusted_origins] = current_trusted_origins(request_for_callbacks(source))
108
171
  end
109
172
 
110
173
  def reset_runtime!
174
+ Thread.current[runtime_key] = nil if request_runtime?
175
+ options.clear_runtime_base_url! if options.respond_to?(:clear_runtime_base_url!)
111
176
  @current_session = nil
112
177
  @new_session = nil
113
178
  end
114
179
 
180
+ def clear_runtime!
181
+ Thread.current[runtime_key] = nil
182
+ options.clear_runtime_base_url! if options.respond_to?(:clear_runtime_base_url!)
183
+ end
184
+
185
+ def refresh_cookies!
186
+ cookies = Cookies.get_cookies(options)
187
+ if request_runtime?
188
+ runtime_store(:auth_cookies, cookies)
189
+ runtime_store(:cookies, cookies)
190
+ else
191
+ @auth_cookies = cookies
192
+ @cookies = cookies
193
+ end
194
+ end
195
+
115
196
  private
116
197
 
117
198
  def inferred_base_url(request)
@@ -120,6 +201,61 @@ module BetterAuth
120
201
  path.empty? ? origin : "#{origin}#{path}"
121
202
  end
122
203
 
204
+ def resolved_dynamic_base_url(request)
205
+ resolved = URLHelpers.resolve_base_url(
206
+ options.base_url_config,
207
+ options.base_path,
208
+ request,
209
+ load_env: true,
210
+ trusted_proxy_headers: dynamic_trusted_proxy_headers?
211
+ )
212
+ origin = Configuration.origin_for(URI.parse(resolved))
213
+ options.set_runtime_base_url(origin) if options.respond_to?(:set_runtime_base_url)
214
+ resolved
215
+ end
216
+
217
+ def dynamic_trusted_proxy_headers?
218
+ return true unless options.advanced.key?(:trusted_proxy_headers)
219
+
220
+ !!options.advanced[:trusted_proxy_headers]
221
+ end
222
+
223
+ def request_for_callbacks(source)
224
+ return source if source.respond_to?(:get_header)
225
+
226
+ DirectAPIRequest.new(source, base_url) if source.is_a?(Hash)
227
+ end
228
+
229
+ def request_runtime
230
+ Thread.current[runtime_key] ||= {
231
+ base_url: @base_url,
232
+ trusted_origins: @trusted_origins,
233
+ auth_cookies: @auth_cookies,
234
+ cookies: @cookies,
235
+ current_session: nil,
236
+ new_session: nil
237
+ }
238
+ end
239
+
240
+ def request_runtime?
241
+ !!Thread.current[runtime_key]
242
+ end
243
+
244
+ def runtime_fetch(key, fallback)
245
+ request_runtime? ? Thread.current[runtime_key].fetch(key, fallback) : fallback
246
+ end
247
+
248
+ def runtime_store(key, value)
249
+ return false unless request_runtime?
250
+
251
+ Thread.current[runtime_key][key] = value
252
+ true
253
+ end
254
+
255
+ def runtime_key
256
+ :"better_auth_context_runtime_#{object_id}"
257
+ end
258
+
123
259
  def inferred_origin(request)
124
260
  forwarded_host = request.get_header("HTTP_X_FORWARDED_HOST")
125
261
  forwarded_proto = request.get_header("HTTP_X_FORWARDED_PROTO")
@@ -207,5 +343,22 @@ module BetterAuth
207
343
  def plugin_context_attribute?(key)
208
344
  ![:options, :adapter, :internal_adapter].include?(key)
209
345
  end
346
+
347
+ class DirectAPIRequest
348
+ attr_reader :headers, :url
349
+
350
+ def initialize(headers, url)
351
+ @headers = headers.transform_keys { |key| key.to_s.downcase }
352
+ @url = url
353
+ end
354
+
355
+ def get_header(key)
356
+ normalized = key.to_s
357
+ .sub(/\AHTTP_/, "")
358
+ .downcase
359
+ .tr("_", "-")
360
+ headers[normalized] || headers[key.to_s] || headers[key.to_s.downcase]
361
+ end
362
+ end
210
363
  end
211
364
  end
@@ -40,7 +40,7 @@ module BetterAuth
40
40
  uri.host unless uri.host.to_s.empty?
41
41
  end
42
42
  end
43
- raise Error, "base_url is required when cross_subdomain_cookies are enabled" if cross_subdomain && domain.to_s.empty?
43
+ raise Error, "base_url is required when cross_subdomain_cookies are enabled" if cross_subdomain && domain.to_s.empty? && !options.dynamic_base_url?
44
44
 
45
45
  custom = advanced.dig(:cookies, cookie_name.to_sym) || {}
46
46
  prefix = advanced[:cookie_prefix] || "better-auth"
@@ -112,7 +112,9 @@ module BetterAuth
112
112
  cookie = ctx.context.auth_cookies[:session_data]
113
113
  max_age = dont_remember_me ? nil : cookie.attributes[:max_age]
114
114
  data = filtered_cache_data(ctx, session)
115
- value = encode_cookie_cache(data, ctx.context.secret, strategy: config[:strategy] || "compact", max_age: max_age || 60 * 5)
115
+ strategy = config[:strategy] || "compact"
116
+ secret = (strategy.to_s == "jwe") ? ctx.context.secret_config : ctx.context.secret
117
+ value = encode_cookie_cache(data, secret, strategy: strategy, max_age: max_age || 60 * 5)
116
118
  attributes = cookie.attributes.merge(max_age: max_age)
117
119
  store = SessionStore.new(cookie.name, attributes, ctx)
118
120
 
@@ -129,7 +131,7 @@ module BetterAuth
129
131
 
130
132
  cookie = ctx.context.auth_cookies[:account_data]
131
133
  attributes = cookie.attributes.merge(max_age: cookie.attributes[:max_age] || 60 * 5)
132
- value = Crypto.symmetric_encode_jwt(stringify_keys(account_data), ctx.context.secret, "better-auth-account", expires_in: attributes[:max_age])
134
+ value = Crypto.symmetric_encode_jwt(stringify_keys(account_data), ctx.context.secret_config, "better-auth-account", expires_in: attributes[:max_age])
133
135
  store = SessionStore.new(cookie.name, attributes, ctx)
134
136
 
135
137
  if value.length > SessionStore::CHUNK_SIZE
@@ -145,7 +147,7 @@ module BetterAuth
145
147
  value = SessionStore.get_chunked_cookie(ctx, cookie.name)
146
148
  return nil unless value
147
149
 
148
- Crypto.symmetric_decode_jwt(value, ctx.context.secret, "better-auth-account")
150
+ Crypto.symmetric_decode_jwt(value, ctx.context.secret_config, "better-auth-account")
149
151
  end
150
152
 
151
153
  def get_cookie_cache(request_or_cookie_header, secret:, strategy: "compact", version: nil, cookie_prefix: "better-auth", cookie_name: "session_data", is_secure: nil)
@@ -32,7 +32,9 @@ module BetterAuth
32
32
  delete_user: Routes.delete_user,
33
33
  delete_user_callback: Routes.delete_user_callback,
34
34
  list_accounts: Routes.list_accounts,
35
+ list_user_accounts: Routes.list_accounts,
35
36
  link_social: Routes.link_social,
37
+ link_social_account: Routes.link_social,
36
38
  unlink_account: Routes.unlink_account,
37
39
  get_access_token: Routes.get_access_token,
38
40
  refresh_token: Routes.refresh_token,
@@ -26,7 +26,7 @@ module BetterAuth
26
26
  "exp" => Time.now.to_i + expires_in.to_i,
27
27
  "jti" => SecureRandom.uuid
28
28
  )
29
- key = encryption_key(secret, salt)
29
+ key = encryption_key(current_secret(secret), salt)
30
30
  ::JWE.encrypt(JSON.generate(claims), key, alg: ALG, enc: ENC, kid: thumbprint(key))
31
31
  end
32
32
 
@@ -35,12 +35,17 @@ module BetterAuth
35
35
 
36
36
  header = protected_header(token)
37
37
  return nil unless valid_header?(header)
38
- return nil unless header["kid"].nil? || header["kid"] == thumbprint(encryption_key(secret, salt))
39
38
 
40
- payload = JSON.parse(::JWE.decrypt(token.to_s, encryption_key(secret, salt)))
41
- return nil if expired?(payload)
39
+ decryption_keys(secret, salt, header["kid"]).each do |key|
40
+ payload = JSON.parse(::JWE.decrypt(token.to_s, key))
41
+ return nil if expired?(payload)
42
42
 
43
- payload
43
+ return payload
44
+ rescue JSON::ParserError, ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK
45
+ next
46
+ end
47
+
48
+ nil
44
49
  rescue JSON::ParserError, ArgumentError, ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK
45
50
  nil
46
51
  end
@@ -57,6 +62,23 @@ module BetterAuth
57
62
  Crypto.base64url_encode(OpenSSL::Digest.digest("SHA256", JSON.generate(jwk)))
58
63
  end
59
64
 
65
+ def current_secret(secret)
66
+ secret.is_a?(SecretConfig) ? secret.current_secret : secret
67
+ end
68
+
69
+ def all_secrets(secret)
70
+ return [[0, secret]] unless secret.is_a?(SecretConfig)
71
+
72
+ secret.all_secrets
73
+ end
74
+
75
+ def decryption_keys(secret, salt, kid)
76
+ keys = all_secrets(secret).map { |_version, value| encryption_key(value, salt) }
77
+ return keys if kid.nil?
78
+
79
+ keys.select { |key| thumbprint(key) == kid }
80
+ end
81
+
60
82
  def protected_header(token)
61
83
  first_segment = token.to_s.split(".", 2).first
62
84
  JSON.parse(Crypto.base64url_decode(first_segment))
@@ -73,6 +73,11 @@ module BetterAuth
73
73
  end
74
74
 
75
75
  def symmetric_encrypt(key:, data:)
76
+ if key.is_a?(SecretConfig)
77
+ ciphertext = symmetric_encrypt(key: key.current_secret, data: data)
78
+ return "#{SecretConfig::ENVELOPE_PREFIX}#{key.current_version}$#{ciphertext}"
79
+ end
80
+
76
81
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
77
82
  cipher.encrypt
78
83
  cipher.key = OpenSSL::Digest.digest("SHA256", key.to_s)
@@ -88,6 +93,20 @@ module BetterAuth
88
93
  end
89
94
 
90
95
  def symmetric_decrypt(key:, data:)
96
+ if key.is_a?(SecretConfig)
97
+ envelope = parse_envelope(data)
98
+ if envelope
99
+ secret = key.keys[envelope[:version]]
100
+ return nil unless secret
101
+
102
+ return symmetric_decrypt(key: secret, data: envelope[:ciphertext])
103
+ end
104
+
105
+ return nil unless key.legacy_secret
106
+
107
+ return symmetric_decrypt(key: key.legacy_secret, data: data)
108
+ end
109
+
91
110
  payload = JSON.parse(base64url_decode(data.to_s))
92
111
  cipher = OpenSSL::Cipher.new("aes-256-gcm")
93
112
  cipher.decrypt
@@ -137,6 +156,19 @@ module BetterAuth
137
156
  value
138
157
  end
139
158
 
159
+ def parse_envelope(data)
160
+ value = data.to_s
161
+ return nil unless value.start_with?(SecretConfig::ENVELOPE_PREFIX)
162
+
163
+ rest = value.delete_prefix(SecretConfig::ENVELOPE_PREFIX)
164
+ version, ciphertext = rest.split("$", 2)
165
+ return nil if version.to_s.empty? || ciphertext.to_s.empty?
166
+
167
+ {version: SecretConfig.parse_version!(version, source: "encrypted envelope"), ciphertext: ciphertext}
168
+ rescue Error
169
+ nil
170
+ end
171
+
140
172
  def keccak256_bytes(input)
141
173
  rate = 136
142
174
  state = Array.new(25, 0)
@@ -12,7 +12,7 @@ module BetterAuth
12
12
  def create(data, model, custom: nil, context: nil)
13
13
  run_before(model, :create, data, context) do |actual_data|
14
14
  created = custom ? custom.call(actual_data) : adapter.create(model: model, data: actual_data, force_allow_id: true)
15
- run_after(model, :create, created)
15
+ run_after(model, :create, created, context)
16
16
  created
17
17
  end
18
18
  end
@@ -20,7 +20,7 @@ module BetterAuth
20
20
  def update(data, where, model, custom: nil, context: nil)
21
21
  run_before(model, :update, data, context) do |actual_data|
22
22
  updated = custom ? custom.call(actual_data) : adapter.update(model: model, where: where, update: actual_data)
23
- run_after(model, :update, updated) if updated
23
+ run_after(model, :update, updated, context) if updated
24
24
  updated
25
25
  end
26
26
  end
@@ -28,16 +28,16 @@ module BetterAuth
28
28
  def update_many(data, where, model, custom: nil, context: nil)
29
29
  run_before(model, :update, data, context) do |actual_data|
30
30
  updated = custom ? custom.call(actual_data) : adapter.update_many(model: model, where: where, update: actual_data)
31
- run_after(model, :update, updated) if updated
31
+ run_after(model, :update, updated, context) if updated
32
32
  updated
33
33
  end
34
34
  end
35
35
 
36
36
  def delete(where, model, custom: nil, context: nil)
37
37
  entity = adapter.find_one(model: model, where: where)
38
- return custom ? custom.call(where) : adapter.delete(model: model, where: where) unless entity
38
+ return nil unless entity
39
39
 
40
- return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
40
+ return false if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
41
41
 
42
42
  deleted = custom ? custom.call(where) : adapter.delete(model: model, where: where)
43
43
  after_hooks(model, :delete).each { |hook| hook.call(entity, context) }
@@ -47,7 +47,7 @@ module BetterAuth
47
47
  def delete_many(where, model, custom: nil, context: nil)
48
48
  entities = adapter.find_many(model: model, where: where)
49
49
  entities.each do |entity|
50
- return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
50
+ return false if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
51
51
  end
52
52
  deleted = custom ? custom.call(where) : adapter.delete_many(model: model, where: where)
53
53
  entities.each { |entity| after_hooks(model, :delete).each { |hook| hook.call(entity, context) } }
@@ -68,8 +68,8 @@ module BetterAuth
68
68
  yield actual_data
69
69
  end
70
70
 
71
- def run_after(model, action, data)
72
- after_hooks(model, action).each { |hook| hook.call(data, nil) }
71
+ def run_after(model, action, data, context)
72
+ after_hooks(model, action).each { |hook| hook.call(data, context) }
73
73
  end
74
74
 
75
75
  def before_hooks(model, action)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Deprecate
5
+ module_function
6
+
7
+ def wrap(message, logger: nil, &block)
8
+ warned = false
9
+ proc do |*args, **kwargs|
10
+ unless warned
11
+ warn_once("[Deprecation] #{message}", logger)
12
+ warned = true
13
+ end
14
+ kwargs.empty? ? block.call(*args) : block.call(*args, **kwargs)
15
+ end
16
+ end
17
+
18
+ def warn_once(message, logger)
19
+ if logger.respond_to?(:call)
20
+ logger.call(message)
21
+ elsif logger.respond_to?(:warn)
22
+ logger.warn(message)
23
+ else
24
+ warn(message)
25
+ end
26
+ end
27
+ end
28
+ end