better_auth 0.8.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def i18n(options = {})
8
+ config = normalize_i18n_options(options)
9
+ validate_i18n_translations!(config)
10
+
11
+ Plugin.new(
12
+ id: "i18n",
13
+ hooks: {
14
+ after: [
15
+ {
16
+ matcher: ->(_ctx) { true },
17
+ handler: ->(ctx) { apply_i18n_translation(ctx, config) }
18
+ }
19
+ ]
20
+ },
21
+ options: config
22
+ )
23
+ end
24
+
25
+ def normalize_i18n_options(options)
26
+ return {} unless options.is_a?(Hash)
27
+
28
+ translations = normalize_i18n_translations(fetch_i18n_option(options, :translations))
29
+ default_locale = fetch_i18n_option(options, :default_locale)
30
+ detection = normalize_i18n_detection(fetch_i18n_option(options, :detection))
31
+ locale_cookie = fetch_i18n_option(options, :locale_cookie) || "locale"
32
+ user_locale_field = fetch_i18n_option(options, :user_locale_field) || "locale"
33
+ get_locale = fetch_i18n_option(options, :get_locale)
34
+ available_locales = translations.keys
35
+
36
+ {
37
+ translations: translations,
38
+ default_locale: resolve_i18n_default_locale(default_locale, available_locales),
39
+ detection: detection,
40
+ locale_cookie: locale_cookie.to_s,
41
+ user_locale_field: user_locale_field.to_s,
42
+ get_locale: get_locale
43
+ }
44
+ end
45
+
46
+ def fetch_i18n_option(options, name)
47
+ snake = name.to_s
48
+ camel = snake.split("_").map.with_index { |part, index| index.zero? ? part : part.capitalize }.join
49
+ options[snake.to_sym] || options[snake] || options[camel.to_sym] || options[camel]
50
+ end
51
+
52
+ def normalize_i18n_translations(translations)
53
+ return {} unless translations.is_a?(Hash)
54
+
55
+ translations.each_with_object({}) do |(locale, codes), result|
56
+ locale_key = locale.to_s
57
+ next if locale_key.empty?
58
+
59
+ normalized_codes = {}
60
+ next unless codes.is_a?(Hash)
61
+
62
+ codes.each do |(code, message)|
63
+ normalized_codes[code.to_s.tr("-", "_").upcase] = message
64
+ end
65
+ result[locale_key] = normalized_codes
66
+ end
67
+ end
68
+
69
+ def normalize_i18n_detection(detection)
70
+ return ["header"] if detection.nil?
71
+
72
+ Array(detection).map { |strategy| strategy.to_s.downcase }
73
+ end
74
+
75
+ def validate_i18n_translations!(config)
76
+ translations = config[:translations]
77
+ if translations.nil? || translations.empty?
78
+ raise BetterAuth::Error, "i18n plugin: translations object is empty"
79
+ end
80
+ end
81
+
82
+ def resolve_i18n_default_locale(explicit, available_locales)
83
+ explicit_locale = explicit.to_s
84
+ return explicit_locale if explicit && available_locales.include?(explicit_locale)
85
+ return "en" if available_locales.include?("en")
86
+
87
+ nil
88
+ end
89
+
90
+ def parse_accept_language(header)
91
+ return [] if header.nil? || header.to_s.empty?
92
+
93
+ header.to_s
94
+ .split(",")
95
+ .map do |part|
96
+ locale_str, quality = part.strip.split(";", 2)
97
+ q_string = quality ? quality.strip.sub(/\Aq=/i, "") : "1"
98
+ q = Float(q_string)
99
+ locale = locale_str.to_s.strip.split("-").first.to_s
100
+ {locale: locale, q: q}
101
+ end
102
+ .select { |item| !item[:locale].empty? }
103
+ .sort_by { |item| -item[:q] }
104
+ .map { |item| item[:locale] }
105
+ end
106
+
107
+ def detect_i18n_locale(ctx, config)
108
+ available_locales = config[:translations].keys
109
+
110
+ config[:detection].each do |strategy|
111
+ locale = case strategy
112
+ when "header"
113
+ detect_locale_from_header(ctx, available_locales)
114
+ when "cookie"
115
+ detect_locale_from_cookie(ctx, config, available_locales)
116
+ when "session"
117
+ detect_locale_from_session(ctx, config, available_locales)
118
+ when "callback"
119
+ detect_locale_from_callback(ctx, config, available_locales)
120
+ end
121
+ return locale if locale && available_locales.include?(locale)
122
+ end
123
+
124
+ config[:default_locale]
125
+ end
126
+
127
+ def detect_locale_from_header(ctx, available_locales)
128
+ parse_accept_language(ctx.headers["accept-language"]).find do |locale|
129
+ available_locales.include?(locale)
130
+ end
131
+ end
132
+
133
+ def detect_locale_from_cookie(ctx, config, available_locales)
134
+ value = ctx.get_cookie(config[:locale_cookie])
135
+ locale = value.to_s
136
+ locale if value && available_locales.include?(locale)
137
+ end
138
+
139
+ def detect_locale_from_session(ctx, config, available_locales)
140
+ session = ctx.context.current_session || ctx.context.new_session
141
+ return nil unless session
142
+
143
+ user = session[:user] || session["user"]
144
+ return nil unless user.is_a?(Hash)
145
+
146
+ locale = fetch_value(user, config[:user_locale_field])
147
+ locale = locale.to_s
148
+ locale if locale && !locale.empty? && available_locales.include?(locale)
149
+ end
150
+
151
+ def detect_locale_from_callback(ctx, config, available_locales)
152
+ callback = config[:get_locale]
153
+ return nil unless callback.respond_to?(:call)
154
+
155
+ locale = callback.call(ctx)
156
+ locale = locale.to_s
157
+ locale if locale && !locale.empty? && available_locales.include?(locale)
158
+ end
159
+
160
+ def apply_i18n_translation(ctx, config)
161
+ error = ctx.returned
162
+ return nil unless error.is_a?(BetterAuth::APIError)
163
+
164
+ error_code = resolve_i18n_error_code(error, ctx)
165
+ return nil unless error_code
166
+
167
+ locale = detect_i18n_locale(ctx, config)
168
+ translation = config.dig(:translations, locale, error_code)
169
+ return nil unless translation
170
+
171
+ raise BetterAuth::APIError.new(
172
+ error.status,
173
+ message: translation,
174
+ headers: error.headers,
175
+ code: error.code,
176
+ body: {
177
+ code: error_code,
178
+ message: translation,
179
+ originalMessage: error.message
180
+ }
181
+ )
182
+ end
183
+
184
+ def resolve_i18n_error_code(error, ctx)
185
+ body = error.body
186
+ if body.is_a?(Hash)
187
+ code = body[:code] || body["code"]
188
+ return code.to_s.tr("-", "_").upcase if code
189
+ end
190
+
191
+ unless error.code.to_s.upcase == error.status.to_s.upcase
192
+ return error.code.to_s.tr("-", "_").upcase
193
+ end
194
+
195
+ reverse_lookup_i18n_error_code(error.message, ctx)
196
+ end
197
+
198
+ def reverse_lookup_i18n_error_code(message, ctx)
199
+ merged_i18n_error_catalog(ctx).find { |_code, text| text == message }&.first
200
+ end
201
+
202
+ def merged_i18n_error_catalog(ctx)
203
+ catalog = BetterAuth::BASE_ERROR_CODES.dup
204
+ ctx.context.options.plugins.each do |plugin|
205
+ next unless plugin.respond_to?(:error_codes)
206
+
207
+ codes = plugin.error_codes
208
+ next if codes.nil? || codes.empty?
209
+
210
+ catalog.merge!(codes)
211
+ end
212
+ catalog
213
+ end
214
+ end
215
+ end
@@ -8,6 +8,8 @@ module BetterAuth
8
8
  module Plugins
9
9
  module JWT
10
10
  SUPPORTED_ALGORITHMS = %w[EdDSA RS256 PS256 ES256 ES512].freeze
11
+ LocalJwksCache = Struct.new(:keys, :expires_at)
12
+ RemoteJwksCache = Struct.new(:entries)
11
13
 
12
14
  module_function
13
15
 
@@ -42,6 +44,10 @@ module BetterAuth
42
44
  def jwt(options = {})
43
45
  config = normalize_hash(options)
44
46
  validate_jwt_options!(config)
47
+ # Mutable caches are owned by this plugin configuration, which is scoped
48
+ # to one auth instance even when endpoint overrides are merged.
49
+ config[:local_jwks_cache] = JWT::LocalJwksCache.new
50
+ config[:remote_jwks_cache] = JWT::RemoteJwksCache.new({})
45
51
  jwks_path = config.dig(:jwks, :jwks_path) || "/jwks"
46
52
 
47
53
  Plugin.new(
@@ -63,6 +69,7 @@ module BetterAuth
63
69
  schema: {
64
70
  jwks: {
65
71
  fields: {
72
+ id: {type: "string", required: true},
66
73
  publicKey: {type: "string", required: true},
67
74
  privateKey: {type: "string", required: true},
68
75
  createdAt: {type: "date", required: true},
@@ -205,8 +212,8 @@ module BetterAuth
205
212
  payload = stringify_payload(payload).dup
206
213
  payload["iat"] ||= now
207
214
  payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
208
- payload["iss"] ||= jwt_config[:issuer] || ctx.context.base_url
209
- payload["aud"] ||= jwt_config[:audience] || ctx.context.base_url
215
+ payload["iss"] ||= jwt_config[:issuer] || ctx.context.canonical_base_url
216
+ payload["aud"] ||= jwt_config[:audience] || ctx.context.canonical_base_url
210
217
 
211
218
  return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
212
219
 
@@ -226,9 +233,9 @@ module BetterAuth
226
233
 
227
234
  options = {
228
235
  algorithm: key["alg"] || "RS256",
229
- iss: config.dig(:jwt, :issuer) || ctx.context.base_url,
236
+ iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url,
230
237
  verify_iss: true,
231
- aud: config.dig(:jwt, :audience) || ctx.context.base_url,
238
+ aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url,
232
239
  verify_aud: true
233
240
  }
234
241
  decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
@@ -238,7 +245,7 @@ module BetterAuth
238
245
  end
239
246
 
240
247
  def latest_jwk(ctx, config)
241
- all_jwks(ctx, config).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
248
+ all_jwks(ctx, config, force_refresh: true).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
242
249
  end
243
250
 
244
251
  def signing_jwk(ctx, config)
@@ -257,13 +264,21 @@ module BetterAuth
257
264
  end
258
265
  end
259
266
 
260
- def all_jwks(ctx, config)
261
- adapter = config[:adapter]
262
- if adapter && adapter[:get_jwks].respond_to?(:call)
263
- return Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
267
+ def all_jwks(ctx, config, force_refresh: false)
268
+ cache = config[:local_jwks_cache] ||= JWT::LocalJwksCache.new
269
+ if !force_refresh && cache.expires_at && cache.expires_at > Time.now
270
+ return cache.keys
264
271
  end
265
272
 
266
- ctx.context.adapter.find_many(model: "jwks")
273
+ adapter = config[:adapter]
274
+ keys = if adapter && adapter[:get_jwks].respond_to?(:call)
275
+ Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
276
+ else
277
+ ctx.context.adapter.find_many(model: "jwks")
278
+ end
279
+ cache.keys = keys
280
+ cache.expires_at = Time.now + 300
281
+ keys
267
282
  end
268
283
 
269
284
  def verification_jwks(ctx, config)
@@ -279,9 +294,15 @@ module BetterAuth
279
294
  payload = if fetcher.respond_to?(:call)
280
295
  fetcher.call(url)
281
296
  else
282
- uri = URI.parse(url.to_s)
283
- response = Net::HTTP.get_response(uri)
284
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
297
+ cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({})
298
+ entry = cached.entries[url.to_s]
299
+ if entry && entry[:expires_at] > Time.now
300
+ entry[:payload]
301
+ else
302
+ fetched = HTTPClient.get_json(url)
303
+ cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
304
+ fetched
305
+ end
285
306
  end
286
307
  keys = fetch_value(payload, "keys")
287
308
  Array(keys).map { |entry| normalize_remote_jwk(entry) }
@@ -313,11 +334,15 @@ module BetterAuth
313
334
  data.merge!(public_key_jwk_fields(public_key, alg))
314
335
  data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
315
336
 
316
- if adapter && adapter[:create_jwk].respond_to?(:call)
317
- return stringify_payload(adapter[:create_jwk].call(data, ctx))
337
+ created = if adapter && adapter[:create_jwk].respond_to?(:call)
338
+ stringify_payload(adapter[:create_jwk].call(data, ctx))
339
+ else
340
+ ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
318
341
  end
319
-
320
- ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
342
+ cache = config[:local_jwks_cache]
343
+ cache.keys = nil if cache
344
+ cache.expires_at = nil if cache
345
+ created
321
346
  end
322
347
 
323
348
  def public_jwk(key, _config)
@@ -441,8 +466,8 @@ module BetterAuth
441
466
  payload = JSON.parse(Crypto.base64url_decode(payload_segment))
442
467
  now = Time.now.to_i
443
468
  return nil if payload["exp"] && payload["exp"].to_i <= now
444
- issuer = config.dig(:jwt, :issuer) || ctx.context.base_url
445
- audience = config.dig(:jwt, :audience) || ctx.context.base_url
469
+ issuer = config.dig(:jwt, :issuer) || ctx.context.canonical_base_url
470
+ audience = config.dig(:jwt, :audience) || ctx.context.canonical_base_url
446
471
  return nil if issuer && payload["iss"] != issuer
447
472
  return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
448
473
  return nil unless jwt_payload_valid?(payload)
@@ -9,6 +9,9 @@ module BetterAuth
9
9
 
10
10
  def magic_link(options = {})
11
11
  config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash(options))
12
+ if options.key?(:allowed_attempts) && options[:allowed_attempts] != 1
13
+ warn "[better-auth/magic-link] `allowed_attempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning."
14
+ end
12
15
 
13
16
  Plugin.new(
14
17
  id: "magic-link",
@@ -54,6 +57,9 @@ module BetterAuth
54
57
  }
55
58
  }
56
59
  ) do |ctx|
60
+ sender = config[:send_magic_link]
61
+ link_base_url = ctx.context.base_url
62
+ link_base_url = ctx.context.token_link_base_url if sender.respond_to?(:call)
57
63
  body = normalize_hash(ctx.body)
58
64
  email = body[:email].to_s.downcase
59
65
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
@@ -66,8 +72,7 @@ module BetterAuth
66
72
  expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i
67
73
  )
68
74
 
69
- link = magic_link_url(ctx, token, body)
70
- sender = config[:send_magic_link]
75
+ link = magic_link_url(link_base_url, token, body)
71
76
  data = {email: email, url: link, token: token}
72
77
  data[:metadata] = body[:metadata] if body.key?(:metadata)
73
78
  sender.call(data, ctx) if sender.respond_to?(:call)
@@ -129,26 +134,12 @@ module BetterAuth
129
134
  end
130
135
 
131
136
  stored_token = store_magic_link_token(token, config)
132
- verification = ctx.context.internal_adapter.find_verification_value(stored_token)
137
+ verification = ctx.context.internal_adapter.consume_verification_value(stored_token)
133
138
  redirect_with_error.call("INVALID_TOKEN") unless verification
134
139
 
135
- if Routes.expired_time?(verification["expiresAt"])
136
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
137
- redirect_with_error.call("EXPIRED_TOKEN")
138
- end
139
-
140
140
  payload = JSON.parse(verification["value"])
141
141
  email = payload.fetch("email").to_s.downcase
142
142
  name = payload["name"]
143
- attempt = payload["attempt"].to_i
144
- if magic_link_attempts_exceeded?(attempt, config)
145
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
146
- redirect_with_error.call("ATTEMPTS_EXCEEDED")
147
- end
148
- ctx.context.internal_adapter.update_verification_value(
149
- verification["id"],
150
- value: JSON.generate(payload.merge("attempt" => attempt + 1))
151
- )
152
143
  found = ctx.context.internal_adapter.find_user_by_email(email)
153
144
  user = found && found[:user]
154
145
  new_user = false
@@ -167,7 +158,13 @@ module BetterAuth
167
158
  end
168
159
 
169
160
  unless user["emailVerified"]
170
- user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
161
+ user = ctx.context.adapter.transaction do
162
+ ctx.context.internal_adapter.revoke_unproven_account_access(user["id"])
163
+ updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
164
+ raise Error, "Failed to verify magic-link user" unless updated
165
+
166
+ updated
167
+ end
171
168
  end
172
169
 
173
170
  session = ctx.context.internal_adapter.create_session(user["id"])
@@ -192,7 +189,7 @@ module BetterAuth
192
189
  generator = config[:generate_token]
193
190
  return generator.call(email) if generator.respond_to?(:call)
194
191
 
195
- Array.new(32) { [*"a".."z", *"A".."Z"].sample }.join
192
+ Crypto.random_string(32, alphabet: Crypto::ALPHABETIC_ALPHABET)
196
193
  end
197
194
 
198
195
  def magic_link_attempts_exceeded?(attempt, config)
@@ -214,14 +211,14 @@ module BetterAuth
214
211
  token
215
212
  end
216
213
 
217
- def magic_link_url(ctx, token, body)
214
+ def magic_link_url(base_url, token, body)
218
215
  params = {
219
216
  token: token,
220
217
  callbackURL: body[:callback_url] || "/"
221
218
  }
222
219
  params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url]
223
220
  params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url]
224
- "#{ctx.context.base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
221
+ "#{base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
225
222
  end
226
223
 
227
224
  def validate_magic_link_callback!(ctx, value, label)