better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -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(
@@ -206,8 +212,8 @@ module BetterAuth
206
212
  payload = stringify_payload(payload).dup
207
213
  payload["iat"] ||= now
208
214
  payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
209
- payload["iss"] ||= jwt_config[:issuer] || ctx.context.base_url
210
- 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
211
217
 
212
218
  return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
213
219
 
@@ -227,9 +233,9 @@ module BetterAuth
227
233
 
228
234
  options = {
229
235
  algorithm: key["alg"] || "RS256",
230
- iss: config.dig(:jwt, :issuer) || ctx.context.base_url,
236
+ iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url,
231
237
  verify_iss: true,
232
- aud: config.dig(:jwt, :audience) || ctx.context.base_url,
238
+ aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url,
233
239
  verify_aud: true
234
240
  }
235
241
  decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
@@ -239,7 +245,7 @@ module BetterAuth
239
245
  end
240
246
 
241
247
  def latest_jwk(ctx, config)
242
- 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) }
243
249
  end
244
250
 
245
251
  def signing_jwk(ctx, config)
@@ -258,13 +264,21 @@ module BetterAuth
258
264
  end
259
265
  end
260
266
 
261
- def all_jwks(ctx, config)
262
- adapter = config[:adapter]
263
- if adapter && adapter[:get_jwks].respond_to?(:call)
264
- 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
265
271
  end
266
272
 
267
- 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
268
282
  end
269
283
 
270
284
  def verification_jwks(ctx, config)
@@ -280,13 +294,13 @@ module BetterAuth
280
294
  payload = if fetcher.respond_to?(:call)
281
295
  fetcher.call(url)
282
296
  else
283
- cached = @jwt_remote_jwks_cache ||= {}
284
- entry = cached[url.to_s]
297
+ cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({})
298
+ entry = cached.entries[url.to_s]
285
299
  if entry && entry[:expires_at] > Time.now
286
300
  entry[:payload]
287
301
  else
288
302
  fetched = HTTPClient.get_json(url)
289
- cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
303
+ cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
290
304
  fetched
291
305
  end
292
306
  end
@@ -320,11 +334,15 @@ module BetterAuth
320
334
  data.merge!(public_key_jwk_fields(public_key, alg))
321
335
  data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
322
336
 
323
- if adapter && adapter[:create_jwk].respond_to?(:call)
324
- 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)
325
341
  end
326
-
327
- 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
328
346
  end
329
347
 
330
348
  def public_jwk(key, _config)
@@ -448,8 +466,8 @@ module BetterAuth
448
466
  payload = JSON.parse(Crypto.base64url_decode(payload_segment))
449
467
  now = Time.now.to_i
450
468
  return nil if payload["exp"] && payload["exp"].to_i <= now
451
- issuer = config.dig(:jwt, :issuer) || ctx.context.base_url
452
- 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
453
471
  return nil if issuer && payload["iss"] != issuer
454
472
  return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
455
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)