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
@@ -98,8 +98,8 @@ module BetterAuth
98
98
  state: state,
99
99
  codeVerifier: code_verifier,
100
100
  code_verifier: code_verifier,
101
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
102
- redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
101
+ redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
102
+ redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
103
103
  scopes: body["scopes"],
104
104
  loginHint: body["loginHint"] || body["login_hint"]
105
105
  })
@@ -135,31 +135,43 @@ module BetterAuth
135
135
  if ctx.method == "POST"
136
136
  merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
137
137
  query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? })
138
- target = "#{ctx.context.base_url}/callback/#{provider_id}"
138
+ target = "#{ctx.context.canonical_base_url}/callback/#{provider_id}"
139
139
  target = "#{target}?#{query}" unless query.empty?
140
140
  raise ctx.redirect(target)
141
141
  end
142
142
 
143
143
  source = ctx.query
144
144
  data = normalize_hash(source)
145
- provider = social_provider(ctx.context, provider_id)
146
145
  state = data["state"].to_s
147
- state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
148
- error_url = state_data ? (state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error") : "#{ctx.context.base_url}/error"
146
+ default_error_url = ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
147
+ state_data = begin
148
+ OAuthState.parse(ctx, state)
149
+ rescue OAuthState::Error => error
150
+ raise ctx.redirect(oauth_error_url(error.error_url || default_error_url, error.code))
151
+ end
152
+ error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || default_error_url
149
153
 
150
154
  raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
151
- raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
152
- raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
153
- raise ctx.redirect(oauth_error_url(error_url, "state_mismatch")) unless valid_oauth_state_cookie?(ctx, state)
154
155
  raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
156
+ provider = social_provider(ctx.context, provider_id)
157
+ raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
155
158
 
156
- tokens = call_provider(provider, :validate_authorization_code, {
157
- code: data["code"],
158
- codeVerifier: state_data["codeVerifier"],
159
- code_verifier: state_data["codeVerifier"],
160
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
161
- redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}"
162
- })
159
+ tokens = begin
160
+ call_provider(provider, :validate_authorization_code, {
161
+ code: data["code"],
162
+ codeVerifier: state_data["codeVerifier"],
163
+ code_verifier: state_data["codeVerifier"],
164
+ redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
165
+ redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}"
166
+ })
167
+ rescue APIError => error
168
+ raise if error.status == "FOUND"
169
+
170
+ nil
171
+ rescue => error
172
+ social_log(ctx.context, :error, "OAuth code validation failed: #{error.message}")
173
+ nil
174
+ end
163
175
  raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens
164
176
 
165
177
  token_data = token_hash(tokens)
@@ -172,6 +184,7 @@ module BetterAuth
172
184
  user = user_info[:user] || user_info["user"] if user_info
173
185
  raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
174
186
  raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
187
+ raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) if blank_remote_id?(fetch_value(user, "id"))
175
188
 
176
189
  link = state_data["link"] || state_data[:link]
177
190
  if link
@@ -192,7 +205,7 @@ module BetterAuth
192
205
  )
193
206
  raise ctx.redirect(oauth_error_url(error_url, session_data[:error].tr(" ", "_"))) if session_data[:error]
194
207
  Cookies.set_session_cookie(ctx, session_data)
195
- callback_url = session_data[:new_user] ? (state_data["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/")
208
+ callback_url = session_data[:new_user] ? (state_data["newUserURL"] || state_data["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/")
196
209
  raise ctx.redirect(callback_url)
197
210
  end
198
211
  end
@@ -258,8 +271,15 @@ module BetterAuth
258
271
  account["providerId"] == provider_id && account["accountId"] == account_id
259
272
  end
260
273
  unless existing
261
- ctx.context.internal_adapter.create_account(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
274
+ begin
275
+ account = ctx.context.internal_adapter.create_account(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
276
+ raise BetterAuth::Error, "Unable to link social account" unless account
277
+ rescue
278
+ social_log(ctx.context, :error, "Unable to link social account")
279
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Unable to link social account")
280
+ end
262
281
  end
282
+ update_social_user_info_on_link(ctx, session[:user]["id"], data[:user])
263
283
  update_verified_email_on_link(ctx, session[:user]["id"], session[:user]["email"], data[:user])
264
284
  next ctx.json({url: "", status: true, redirect: false})
265
285
  end
@@ -281,8 +301,8 @@ module BetterAuth
281
301
  state: state,
282
302
  codeVerifier: code_verifier,
283
303
  code_verifier: code_verifier,
284
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
285
- redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
304
+ redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
305
+ redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
286
306
  scopes: body["scopes"],
287
307
  loginHint: body["loginHint"] || body["login_hint"]
288
308
  })
@@ -313,6 +333,7 @@ module BetterAuth
313
333
  user = user_info[:user] || user_info["user"] if user_info
314
334
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user
315
335
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["USER_EMAIL_NOT_FOUND"]) if fetch_value(user, "email").to_s.empty?
336
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) if blank_remote_id?(fetch_value(user, "id"))
316
337
 
317
338
  {
318
339
  user: user,
@@ -327,46 +348,84 @@ module BetterAuth
327
348
  }
328
349
  end
329
350
 
330
- def self.persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false)
351
+ def self.persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false, override_user_info: false)
331
352
  email = fetch_value(user_info, "email").to_s.downcase
332
353
  account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s
333
- existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
354
+ return {error: "unable to get user info"} if blank_remote_id?(account_id)
355
+
356
+ existing = begin
357
+ ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
358
+ rescue
359
+ social_log(ctx.context, :error, "Unable to query social user")
360
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "internal server error")
361
+ end
334
362
 
335
363
  if existing && existing[:linked_account]
336
364
  user = existing[:user]
337
365
  if ctx.context.options.account[:update_account_on_sign_in] != false
338
366
  update_data = account_storage_fields(account_info)
339
- ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data) unless update_data.empty?
367
+ unless update_data.empty?
368
+ begin
369
+ ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data)
370
+ rescue
371
+ social_log(ctx.context, :error, "Unable to link social account")
372
+ raise
373
+ end
374
+ end
340
375
  end
341
376
  verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
342
377
  user = verified_user if verified_user
343
378
  new_user = false
344
379
  elsif existing
345
- unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
346
- return {error: "account not linked"}
380
+ linkable = linkable_provider?(ctx, provider_id, user_info, implicit: true, local_user: existing[:user])
381
+ unless linkable
382
+ return {error: existing[:user]["banned"] ? "banned" : "account not linked"}
347
383
  end
348
384
  user = existing[:user]
349
- ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
350
- verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
351
- user = verified_user if verified_user
385
+ user = ctx.context.adapter.transaction do
386
+ account = begin
387
+ ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
388
+ rescue
389
+ social_log(ctx.context, :error, "Unable to link social account")
390
+ raise
391
+ end
392
+ unless account
393
+ social_log(ctx.context, :error, "Unable to link social account")
394
+ raise BetterAuth::Error, "Failed to link social account"
395
+ end
396
+
397
+ promoted = promote_verified_email_on_link!(ctx, user, user_info)
398
+ linked_user = promoted || user
399
+ update_social_user_info_on_link(ctx, linked_user["id"], user_info) || linked_user
400
+ end
352
401
  new_user = false
353
402
  else
354
403
  return {error: "signup disabled"} if disable_sign_up
355
404
 
356
- created = ctx.context.internal_adapter.create_oauth_user(
357
- {
358
- email: email,
359
- name: fetch_value(user_info, "name").to_s,
360
- image: fetch_value(user_info, "image"),
361
- emailVerified: !!fetch_value(user_info, "emailVerified")
362
- },
363
- account_info.merge("providerId" => provider_id, "accountId" => account_id),
364
- context: ctx
365
- )
405
+ begin
406
+ provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, user_info, action: :create)
407
+ created = ctx.context.internal_adapter.create_oauth_user(
408
+ provider_fields.merge(
409
+ email: email,
410
+ name: fetch_value(user_info, "name").to_s,
411
+ image: fetch_value(user_info, "image"),
412
+ emailVerified: !!fetch_value(user_info, "emailVerified")
413
+ ),
414
+ account_info.merge("providerId" => provider_id, "accountId" => account_id),
415
+ context: ctx
416
+ )
417
+ raise BetterAuth::Error, "Failed to create social user" unless created
418
+ rescue
419
+ social_log(ctx.context, :error, "Unable to create social user")
420
+ return {error: "unable to create user"}
421
+ end
366
422
  user = created[:user]
367
423
  new_user = true
368
424
  end
369
- user = override_social_user_info(ctx, user, user_info) if existing && provider_override_user_info_on_sign_in?(provider_id, ctx.context)
425
+ if existing && (override_user_info || provider_override_user_info_on_sign_in?(provider_id, ctx.context))
426
+ user = override_social_user_info(ctx, user, user_info)
427
+ end
428
+ return {error: "unable to create user"} unless user
370
429
 
371
430
  session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
372
431
  {session: session, user: user, new_user: new_user}
@@ -405,17 +464,32 @@ module BetterAuth
405
464
  !!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
406
465
  end
407
466
 
408
- def self.linkable_provider?(ctx, provider_id, user_info, implicit: false)
467
+ def self.linkable_provider?(ctx, provider_id, user_info, implicit: false, local_user: nil)
409
468
  linking = ctx.context.options.account[:account_linking] || {}
410
469
  return false if linking[:enabled] == false
411
470
  return false if implicit && linking[:disable_implicit_linking] == true
471
+ if implicit && linking.fetch(:require_local_email_verified, true) && !local_user&.fetch("emailVerified", false)
472
+ return false
473
+ end
412
474
 
413
475
  trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
414
476
  trusted || !!fetch_value(user_info, "emailVerified")
415
477
  end
416
478
 
479
+ def self.implicit_link_allowed?(ctx, local_user)
480
+ linking = ctx.context.options.account[:account_linking] || {}
481
+ return false if linking[:enabled] == false || linking[:disable_implicit_linking] == true
482
+
483
+ linking[:require_local_email_verified] == false || !!local_user["emailVerified"]
484
+ end
485
+
486
+ def self.blank_remote_id?(value)
487
+ value.nil? || value.to_s.strip.empty?
488
+ end
489
+
417
490
  def self.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link)
418
491
  return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, user_info)
492
+ return {error: "unable_to_link_account"} if blank_remote_id?(fetch_value(user_info, "id"))
419
493
 
420
494
  email = fetch_value(user_info, "email").to_s.downcase
421
495
  link_email = fetch_value(link, "email").to_s.downcase
@@ -431,20 +505,21 @@ module BetterAuth
431
505
  "userId" => user_id
432
506
  )
433
507
  existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id)
434
- if existing
435
- return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id
508
+ begin
509
+ account = if existing
510
+ return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id
436
511
 
437
- ctx.context.internal_adapter.update_account(existing["id"], account_info)
438
- else
439
- ctx.context.internal_adapter.create_account(account_info)
512
+ ctx.context.internal_adapter.update_account(existing["id"], account_info)
513
+ else
514
+ ctx.context.internal_adapter.create_account(account_info)
515
+ end
516
+ raise BetterAuth::Error, "Unable to link social account" unless account
517
+ rescue
518
+ social_log(ctx.context, :error, "Unable to link social account")
519
+ return {error: "unable_to_link_account"}
440
520
  end
441
521
 
442
- if ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
443
- ctx.context.internal_adapter.update_user(user_id, {
444
- name: fetch_value(user_info, "name"),
445
- image: fetch_value(user_info, "image")
446
- }.compact)
447
- end
522
+ update_social_user_info_on_link(ctx, user_id, user_info)
448
523
  update_verified_email_on_link(ctx, user_id, link_email, user_info)
449
524
 
450
525
  {status: true}
@@ -462,13 +537,33 @@ module BetterAuth
462
537
  else
463
538
  !!fetch_value(user_info, "emailVerified")
464
539
  end
465
- update = {
540
+ provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, user_info, action: :update)
541
+ update = provider_fields.merge(
466
542
  "email" => email,
467
543
  "name" => fetch_value(user_info, "name").to_s,
468
544
  "image" => fetch_value(user_info, "image"),
469
545
  "emailVerified" => email_verified
470
- }.reject { |_key, value| value.nil? }
471
- ctx.context.internal_adapter.update_user(user["id"], update) || user
546
+ ).reject { |_key, value| value.nil? }
547
+ ctx.context.internal_adapter.update_user(user["id"], update)
548
+ rescue
549
+ social_log(ctx.context, :warn, "Could not override social user info")
550
+ raise
551
+ end
552
+
553
+ def self.update_social_user_info_on_link(ctx, user_id, user_info)
554
+ return unless ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
555
+
556
+ provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, user_info, action: :update)
557
+ update = provider_fields.merge(
558
+ "name" => fetch_value(user_info, "name"),
559
+ "image" => fetch_value(user_info, "image")
560
+ ).reject { |_key, value| value.nil? }
561
+ return if update.empty?
562
+
563
+ ctx.context.internal_adapter.update_user(user_id, update)
564
+ rescue
565
+ social_log(ctx.context, :warn, "Could not update user info on account link")
566
+ nil
472
567
  end
473
568
 
474
569
  def self.safe_additional_state(body)
@@ -507,6 +602,17 @@ module BetterAuth
507
602
  ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true})
508
603
  end
509
604
 
605
+ def self.promote_verified_email_on_link!(ctx, local_user, social_user)
606
+ return if local_user["emailVerified"] == true
607
+ return unless fetch_value(social_user, "emailVerified")
608
+ return unless fetch_value(social_user, "email").to_s.strip.downcase == local_user["email"].to_s.strip.downcase
609
+
610
+ updated = ctx.context.internal_adapter.update_user(local_user.fetch("id"), {"emailVerified" => true})
611
+ raise BetterAuth::Error, "Failed to promote verified email" unless updated
612
+
613
+ updated
614
+ end
615
+
510
616
  def self.parse_json_hash(value)
511
617
  return value if value.is_a?(Hash)
512
618
  return {} if value.nil? || value.to_s.empty?
@@ -516,5 +622,14 @@ module BetterAuth
516
622
  rescue JSON::ParserError
517
623
  {}
518
624
  end
625
+
626
+ def self.social_log(context, level, message)
627
+ logger = context.logger
628
+ if logger.respond_to?(:call)
629
+ logger.call(level, message)
630
+ elsif logger.respond_to?(level)
631
+ logger.public_send(level, message)
632
+ end
633
+ end
519
634
  end
520
635
  end
@@ -89,7 +89,7 @@ module BetterAuth
89
89
  ctx.context.internal_adapter.update_account(account["id"], password: hash_password(ctx, new_password))
90
90
  token = nil
91
91
  if body["revokeOtherSessions"] || body["revoke_other_sessions"]
92
- ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
92
+ ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
93
93
  new_session = ctx.context.internal_adapter.create_session(session[:user]["id"])
94
94
  Cookies.set_session_cookie(ctx, {session: new_session, user: session[:user]})
95
95
  token = new_session["token"]
@@ -176,6 +176,7 @@ module BetterAuth
176
176
  session = current_session(ctx, sensitive: true)
177
177
  body = normalize_hash(ctx.body)
178
178
  sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification)
179
+ token_link_base_url = ctx.context.token_link_base_url if !body["token"] && sender.respond_to?(:call)
179
180
  if body["password"]
180
181
  account = credential_account(ctx, session[:user]["id"])
181
182
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
@@ -191,7 +192,7 @@ module BetterAuth
191
192
  token = SecureRandom.hex(16)
192
193
  expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
193
194
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/"
194
- url = "#{ctx.context.base_url}/delete-user/callback?token=#{URI.encode_www_form_component(token)}&callbackURL=#{URI.encode_www_form_component(callback_url)}"
195
+ url = "#{token_link_base_url}/delete-user/callback?token=#{URI.encode_www_form_component(token)}&callbackURL=#{URI.encode_www_form_component(callback_url)}"
195
196
  ctx.context.internal_adapter.create_verification_value(
196
197
  identifier: "delete-account-#{token}",
197
198
  value: session[:user]["id"],
@@ -298,7 +299,7 @@ module BetterAuth
298
299
  }
299
300
  ) do |ctx|
300
301
  enabled = ctx.context.options.user.dig(:change_email, :enabled)
301
- raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
302
+ raise APIError.new("BAD_REQUEST", code: "CHANGE_EMAIL_DISABLED", message: BASE_ERROR_CODES["CHANGE_EMAIL_DISABLED"]) unless enabled
302
303
  session = current_session(ctx, sensitive: true)
303
304
  body = normalize_hash(ctx.body)
304
305
  new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
@@ -312,6 +313,7 @@ module BetterAuth
312
313
  unless can_update_without_verification || can_send_confirmation || can_send_verification
313
314
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"])
314
315
  end
316
+ ctx.context.token_link_base_url if can_send_confirmation || can_send_verification
315
317
 
316
318
  existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
317
319
  next ctx.json({status: true}) if existing_target
@@ -326,6 +328,7 @@ module BetterAuth
326
328
  if can_send_confirmation
327
329
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
328
330
  token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-confirmation"})
331
+ register_change_email_token!(ctx, token)
329
332
  url = email_verification_url(ctx, token, callback_url)
330
333
  confirmation_sender.call({user: session[:user], new_email: new_email, url: url, token: token}, ctx.request)
331
334
  next ctx.json({status: true})
@@ -338,20 +341,20 @@ module BetterAuth
338
341
 
339
342
  def self.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url)
340
343
  token = create_email_verification_token(ctx, current_email, update_to: new_email, extra: {"requestType" => "change-email-verification"})
344
+ register_change_email_token!(ctx, token)
341
345
  sender.call({user: user.merge("email" => new_email), url: email_verification_url(ctx, token, callback_url), token: token}, ctx.request)
342
346
  end
343
347
 
344
348
  def self.email_verification_url(ctx, token, callback_url)
345
349
  callback = URI.encode_www_form_component(callback_url || "/")
346
- "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
350
+ "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
347
351
  end
348
352
 
349
353
  def self.delete_user_by_token!(ctx, session, token)
350
- verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
351
- unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
354
+ verification = ctx.context.internal_adapter.consume_verification_value("delete-account-#{token}")
355
+ unless verification && verification["value"] == session[:user]["id"]
352
356
  raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"])
353
357
  end
354
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
355
358
  end
356
359
 
357
360
  def self.delete_current_user!(ctx, session)
@@ -360,7 +363,7 @@ module BetterAuth
360
363
  deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"])
361
364
  raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false
362
365
 
363
- ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
366
+ ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
364
367
  Cookies.delete_session_cookie(ctx)
365
368
  call_option(config[:after_delete], session[:user], ctx.request)
366
369
  end
@@ -7,18 +7,22 @@ module BetterAuth
7
7
 
8
8
  def create_statements(options, dialect:)
9
9
  dialect = dialect.to_sym
10
- tables = Schema.auth_tables(options)
11
- statements = tables.map { |logical_name, table| create_table_statement(logical_name, table, dialect, tables) }
10
+ tables = Schema.migration_tables(options)
11
+ delete_actions = mssql_delete_actions(tables, dialect)
12
+ statements = tables.map do |logical_name, table|
13
+ create_table_statement(logical_name, table, dialect, tables, delete_actions: delete_actions)
14
+ end
12
15
  statements.concat(tables.flat_map { |_logical_name, table| index_statements(table, dialect) })
13
16
  end
14
17
 
15
18
  def pending_statements(plan)
19
+ delete_actions = mssql_delete_actions(plan.tables, plan.dialect)
16
20
  statements = plan.to_create.map do |change|
17
- create_table_statement(change.logical_name, change.table, plan.dialect, plan.tables)
21
+ create_table_statement(change.logical_name, change.table, plan.dialect, plan.tables, delete_actions: delete_actions)
18
22
  end
19
23
  statements.concat(plan.to_add.flat_map do |change|
20
24
  change.fields.map do |logical_field, attributes|
21
- if logical_field.to_s == "id" && plan.dialect == :postgres
25
+ if logical_name(logical_field, attributes) == "id" && plan.dialect == :postgres
22
26
  add_postgres_id_column_statements(change.table_name)
23
27
  else
24
28
  add_column_statement(change.table_name, logical_field, attributes, plan.dialect)
@@ -37,13 +41,13 @@ module BetterAuth
37
41
  end)
38
42
  end
39
43
 
40
- def create_table_statement(logical_name, table, dialect, tables = nil)
44
+ def create_table_statement(logical_name, table, dialect, tables = nil, delete_actions: nil)
41
45
  table_name = table.fetch(:model_name)
42
46
  columns = table.fetch(:fields).map do |logical_field, attributes|
43
47
  column_definition(table_name, logical_field, attributes, dialect)
44
48
  end
45
49
  constraints = table.fetch(:fields).flat_map do |logical_field, attributes|
46
- field_constraints(table_name, logical_field, attributes, dialect, tables)
50
+ field_constraints(table_name, logical_field, attributes, dialect, tables, delete_actions: delete_actions)
47
51
  end
48
52
  body = (columns + constraints).join(",\n ")
49
53
 
@@ -61,8 +65,8 @@ module BetterAuth
61
65
 
62
66
  def column_definition(table_name, logical_field, attributes, dialect)
63
67
  column = quote(attributes[:field_name] || physical_name(logical_field), dialect)
64
- parts = [column, sql_type(logical_field, attributes, dialect)]
65
- parts << "PRIMARY KEY" if logical_field == "id"
68
+ parts = [column, sql_type(logical_name(logical_field, attributes), attributes, dialect)]
69
+ parts << "PRIMARY KEY" if logical_name(logical_field, attributes) == "id"
66
70
  if attributes[:required]
67
71
  parts << "NOT NULL"
68
72
  elsif dialect == :mssql
@@ -73,17 +77,17 @@ module BetterAuth
73
77
  parts.join(" ")
74
78
  end
75
79
 
76
- def field_constraints(table_name, logical_field, attributes, dialect, tables = nil)
80
+ def field_constraints(table_name, logical_field, attributes, dialect, tables = nil, delete_actions: nil)
77
81
  constraints = []
78
82
  column = attributes[:field_name] || physical_name(logical_field)
79
83
 
80
- if attributes[:unique] && logical_field != "id" && !(dialect == :mssql && !attributes[:required])
84
+ if attributes[:unique] && logical_name(logical_field, attributes) != "id" && !(dialect == :mssql && !attributes[:required])
81
85
  constraints << unique_constraint(table_name, column, dialect)
82
86
  end
83
87
 
84
88
  reference = attributes[:references]
85
89
  if reference
86
- constraints << foreign_key_constraint(table_name, column, reference, dialect, tables)
90
+ constraints << foreign_key_constraint(table_name, column, reference, dialect, tables, delete_actions: delete_actions)
87
91
  end
88
92
 
89
93
  constraints
@@ -92,7 +96,7 @@ module BetterAuth
92
96
  def index_statements(table, dialect)
93
97
  table_name = table.fetch(:model_name)
94
98
  table.fetch(:fields).filter_map do |logical_field, attributes|
95
- nullable_unique_mssql = dialect == :mssql && attributes[:unique] && logical_field != "id" && !attributes[:required]
99
+ nullable_unique_mssql = dialect == :mssql && attributes[:unique] && logical_name(logical_field, attributes) != "id" && !attributes[:required]
96
100
  next if attributes[:unique] && !nullable_unique_mssql
97
101
  next unless attributes[:index] || nullable_unique_mssql
98
102
 
@@ -149,7 +153,8 @@ module BetterAuth
149
153
  end
150
154
 
151
155
  def sql_type(logical_field, attributes, dialect)
152
- case attributes[:type]
156
+ type = attributes[:type] || "string"
157
+ case type
153
158
  when "boolean"
154
159
  case dialect
155
160
  when :mysql
@@ -185,16 +190,22 @@ module BetterAuth
185
190
  else
186
191
  "text"
187
192
  end
193
+ when "string"
194
+ indexed_string_sql_type(logical_field, attributes, dialect)
188
195
  else
189
- if dialect == :mysql
190
- indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable] || attributes.key?(:default_value)
191
- indexed ? "varchar(191)" : "text"
192
- elsif dialect == :mssql
193
- indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
194
- indexed ? "varchar(255)" : "varchar(8000)"
195
- else
196
- "text"
197
- end
196
+ raise BetterAuth::Error, "Unsupported field type: #{type}"
197
+ end
198
+ end
199
+
200
+ def indexed_string_sql_type(logical_field, attributes, dialect)
201
+ if dialect == :mysql
202
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable] || attributes.key?(:default_value)
203
+ indexed ? "varchar(191)" : "text"
204
+ elsif dialect == :mssql
205
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
206
+ indexed ? "varchar(255)" : "varchar(8000)"
207
+ else
208
+ "text"
198
209
  end
199
210
  end
200
211
 
@@ -229,11 +240,12 @@ module BetterAuth
229
240
  end
230
241
  end
231
242
 
232
- def foreign_key_constraint(table_name, column, reference, dialect, tables = nil)
243
+ def foreign_key_constraint(table_name, column, reference, dialect, tables = nil, delete_actions: nil)
233
244
  target_table = foreign_key_target_table(reference, tables)
234
245
  target_model = target_table&.fetch(:model_name) || reference.fetch(:model)
235
246
  target_field = foreign_key_target_field(reference, target_table)
236
- on_delete = reference[:on_delete] ? " ON DELETE #{reference[:on_delete].to_s.upcase}" : ""
247
+ delete_action = delete_actions&.fetch([table_name.to_s, column.to_s], reference[:on_delete]) || reference[:on_delete]
248
+ on_delete = delete_action ? " ON DELETE #{delete_action.to_s.upcase}" : ""
237
249
 
238
250
  case dialect
239
251
  when :postgres, :sqlite
@@ -245,6 +257,56 @@ module BetterAuth
245
257
  end
246
258
  end
247
259
 
260
+ def mssql_delete_actions(tables, dialect)
261
+ return unless dialect.to_sym == :mssql
262
+
263
+ graph = {}
264
+ tables.each_with_object({}) do |(_logical_name, table), actions|
265
+ child = table.fetch(:model_name).to_s
266
+ table.fetch(:fields).each do |logical_field, attributes|
267
+ reference = attributes[:references]
268
+ next unless reference
269
+
270
+ column = (attributes[:field_name] || physical_name(logical_field)).to_s
271
+ action = reference[:on_delete]&.to_s
272
+ key = [child, column]
273
+ unless mssql_cascading_action?(action)
274
+ actions[key] = action
275
+ next
276
+ end
277
+
278
+ target_table = foreign_key_target_table(reference, tables)
279
+ parent = (target_table&.fetch(:model_name) || reference.fetch(:model)).to_s
280
+ if mssql_cascade_conflict?(graph, parent, child)
281
+ actions[key] = "no action"
282
+ else
283
+ graph[parent] ||= []
284
+ graph[parent] << child unless graph[parent].include?(child)
285
+ actions[key] = action
286
+ end
287
+ end
288
+ end
289
+ end
290
+
291
+ def mssql_cascading_action?(action)
292
+ %w[cascade set\ null set\ default].include?(action.to_s.downcase.tr("_", " "))
293
+ end
294
+
295
+ def mssql_cascade_conflict?(graph, parent, child)
296
+ nodes = (graph.keys + graph.values.flatten + [parent, child]).uniq
297
+ nodes.any? do |source|
298
+ mssql_reachable?(graph, source, parent) && mssql_reachable?(graph, source, child)
299
+ end
300
+ end
301
+
302
+ def mssql_reachable?(graph, source, target, visited = {})
303
+ return true if source == target
304
+ return false if visited[source]
305
+
306
+ visited[source] = true
307
+ Array(graph[source]).any? { |child| mssql_reachable?(graph, child, target, visited) }
308
+ end
309
+
248
310
  def foreign_key_target_table(reference, tables)
249
311
  return unless tables
250
312
 
@@ -286,6 +348,10 @@ module BetterAuth
286
348
  .tr("-", "_")
287
349
  .downcase
288
350
  end
351
+
352
+ def logical_name(logical_field, attributes)
353
+ (attributes[:logical_name] || logical_field).to_s
354
+ end
289
355
  end
290
356
  end
291
357
  end