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
@@ -58,6 +58,7 @@ module BetterAuth
58
58
  if email_config[:enabled] != true || email_config[:disable_sign_up]
59
59
  raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_SIGN_UP_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_SIGN_UP_DISABLED"])
60
60
  end
61
+ preflight_sign_up_token_link!(ctx)
61
62
 
62
63
  body = normalize_hash(ctx.body)
63
64
  name = body["name"].to_s
@@ -69,14 +70,16 @@ module BetterAuth
69
70
 
70
71
  validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
71
72
  validate_sign_up_input!(email, password, email_config)
73
+ reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
74
+ additional_user_fields = parse_sign_up_additional_user_fields(ctx, body.except(*reserved))
72
75
 
73
76
  ctx.context.adapter.transaction do
74
77
  existing = ctx.context.internal_adapter.find_user_by_email(email)
75
78
  if existing
76
- if email_config[:require_email_verification]
79
+ if email_config[:require_email_verification] || email_config[:auto_sign_in] == false
77
80
  hash_password(ctx, password)
78
81
  call_existing_sign_up_callback(ctx, email_config, existing)
79
- synthetic_user = synthetic_sign_up_user(ctx, body, email, name, image)
82
+ synthetic_user = synthetic_sign_up_user(ctx, additional_user_fields, email, name, image)
80
83
  next ctx.json({token: nil, user: Schema.parse_output(options, "user", synthetic_user)})
81
84
  end
82
85
 
@@ -87,7 +90,7 @@ module BetterAuth
87
90
  end
88
91
 
89
92
  hashed_password = hash_password(ctx, password)
90
- created_user = create_sign_up_user(ctx, body, email, name, image)
93
+ created_user = create_sign_up_user(ctx, additional_user_fields, email, name, image)
91
94
  raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless created_user
92
95
 
93
96
  ctx.context.internal_adapter.link_account(
@@ -144,9 +147,7 @@ module BetterAuth
144
147
  raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
145
148
  end
146
149
 
147
- def self.create_sign_up_user(ctx, body, email, name, image)
148
- reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
149
- additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
150
+ def self.create_sign_up_user(ctx, additional, email, name, image)
150
151
  ctx.context.internal_adapter.create_user(
151
152
  additional.merge(
152
153
  "email" => email.downcase,
@@ -175,7 +176,7 @@ module BetterAuth
175
176
  end
176
177
  end
177
178
 
178
- def self.synthetic_sign_up_user(ctx, body, email, name, image)
179
+ def self.synthetic_sign_up_user(ctx, additional, email, name, image)
179
180
  now = Time.now
180
181
  core_fields = {
181
182
  "id" => SecureRandom.hex(16),
@@ -186,8 +187,6 @@ module BetterAuth
186
187
  "createdAt" => now,
187
188
  "updatedAt" => now
188
189
  }
189
- reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
190
- additional = synthetic_additional_user_fields(ctx, body.except(*reserved))
191
190
  custom = ctx.context.options.email_and_password[:custom_synthetic_user]
192
191
  return core_fields.merge(additional) unless custom.respond_to?(:call)
193
192
 
@@ -201,20 +200,37 @@ module BetterAuth
201
200
  stringify_synthetic_user(custom.call(value))
202
201
  end
203
202
 
204
- def self.synthetic_additional_user_fields(ctx, data)
205
- additional = parse_declared_input(ctx, "user", data, allowed_base: [])
206
- configured = ctx.context.options.user[:additional_fields] || {}
207
- configured.each do |field, attributes|
208
- storage_field = Schema.storage_key(field)
209
- next if additional.key?(storage_field)
210
-
211
- field_attributes = normalize_hash(attributes || {})
212
- next unless field_attributes.key?("defaultValue") || field_attributes.key?("default_value")
203
+ def self.parse_sign_up_additional_user_fields(ctx, data)
204
+ input = normalize_hash(data || {})
205
+ fields = Schema.auth_tables(ctx.context.options).fetch("user").fetch(:fields)
206
+ .except(*core_model_fields("user"))
207
+
208
+ fields.each_with_object({}) do |(field, attributes), result|
209
+ if input.key?(field)
210
+ if attributes[:input] == false
211
+ if attributes.key?(:default_value)
212
+ result[field] = resolve_default(attributes[:default_value])
213
+ elsif js_truthy_input?(input[field])
214
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
215
+ end
216
+ next
217
+ end
213
218
 
214
- default = field_attributes.key?("defaultValue") ? field_attributes["defaultValue"] : field_attributes["default_value"]
215
- additional[storage_field] = resolve_default(default)
219
+ result[field] = coerce_input_value(input[field], attributes)
220
+ elsif attributes.key?(:default_value)
221
+ result[field] = resolve_default(attributes[:default_value])
222
+ elsif attributes[:required]
223
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required")
224
+ end
216
225
  end
217
- additional
226
+ end
227
+
228
+ def self.js_truthy_input?(value)
229
+ return false if value.nil? || value == false
230
+ return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
231
+ return false if value.is_a?(String) && value.empty?
232
+
233
+ true
218
234
  end
219
235
 
220
236
  def self.resolve_default(value)
@@ -242,10 +258,17 @@ module BetterAuth
242
258
  expires_in: verification[:expires_in] || 3600
243
259
  )
244
260
  callback = URI.encode_www_form_component(callback_url || "/")
245
- url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
261
+ url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
246
262
  sender.call({user: user, url: url, token: token}, ctx.request)
247
263
  end
248
264
 
265
+ def self.preflight_sign_up_token_link!(ctx)
266
+ verification = ctx.context.options.email_verification
267
+ password_config = ctx.context.options.email_and_password
268
+ send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
269
+ ctx.context.token_link_base_url if send_on_sign_up && verification[:send_verification_email].respond_to?(:call)
270
+ end
271
+
249
272
  def self.session_overrides(ctx)
250
273
  {
251
274
  ipAddress: RequestIP.client_ip(ctx, ctx.context.options).to_s,
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "uri"
4
4
  require "json"
5
+ require "net/http"
5
6
  require "securerandom"
6
7
 
7
8
  module BetterAuth
@@ -92,12 +93,13 @@ module BetterAuth
92
93
  ctx.context.secret,
93
94
  expires_in: 600
94
95
  )
96
+ store_oauth_state_cookie(ctx, state)
95
97
  url = call_provider(provider, :create_authorization_url, {
96
98
  state: state,
97
99
  codeVerifier: code_verifier,
98
100
  code_verifier: code_verifier,
99
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
100
- 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}",
101
103
  scopes: body["scopes"],
102
104
  loginHint: body["loginHint"] || body["login_hint"]
103
105
  })
@@ -133,38 +135,56 @@ module BetterAuth
133
135
  if ctx.method == "POST"
134
136
  merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
135
137
  query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? })
136
- target = "#{ctx.context.base_url}/callback/#{provider_id}"
138
+ target = "#{ctx.context.canonical_base_url}/callback/#{provider_id}"
137
139
  target = "#{target}?#{query}" unless query.empty?
138
140
  raise ctx.redirect(target)
139
141
  end
140
142
 
141
143
  source = ctx.query
142
144
  data = normalize_hash(source)
143
- provider = social_provider(ctx.context, provider_id)
144
145
  state = data["state"].to_s
145
- state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
146
- 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
147
153
 
148
154
  raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
149
- raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
150
- raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
151
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
152
158
 
153
- tokens = call_provider(provider, :validate_authorization_code, {
154
- code: data["code"],
155
- codeVerifier: state_data["codeVerifier"],
156
- code_verifier: state_data["codeVerifier"],
157
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
158
- redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}"
159
- })
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
160
175
  raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens
161
176
 
162
177
  token_data = token_hash(tokens)
163
178
  token_data["user"] = parse_json_hash(data["user"]) if data["user"]
164
- user_info = call_provider(provider, :get_user_info, token_data)
179
+ user_info = begin
180
+ call_provider(provider, :get_user_info, token_data)
181
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
182
+ nil
183
+ end
165
184
  user = user_info[:user] || user_info["user"] if user_info
166
185
  raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
167
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"))
168
188
 
169
189
  link = state_data["link"] || state_data[:link]
170
190
  if link
@@ -185,7 +205,7 @@ module BetterAuth
185
205
  )
186
206
  raise ctx.redirect(oauth_error_url(error_url, session_data[:error].tr(" ", "_"))) if session_data[:error]
187
207
  Cookies.set_session_cookie(ctx, session_data)
188
- 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"] || "/")
189
209
  raise ctx.redirect(callback_url)
190
210
  end
191
211
  end
@@ -251,8 +271,15 @@ module BetterAuth
251
271
  account["providerId"] == provider_id && account["accountId"] == account_id
252
272
  end
253
273
  unless existing
254
- 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
255
281
  end
282
+ update_social_user_info_on_link(ctx, session[:user]["id"], data[:user])
256
283
  update_verified_email_on_link(ctx, session[:user]["id"], session[:user]["email"], data[:user])
257
284
  next ctx.json({url: "", status: true, redirect: false})
258
285
  end
@@ -269,12 +296,13 @@ module BetterAuth
269
296
  }
270
297
  }.merge(safe_additional_state(body))
271
298
  state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
299
+ store_oauth_state_cookie(ctx, state)
272
300
  url = call_provider(provider, :create_authorization_url, {
273
301
  state: state,
274
302
  codeVerifier: code_verifier,
275
303
  code_verifier: code_verifier,
276
- redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
277
- 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}",
278
306
  scopes: body["scopes"],
279
307
  loginHint: body["loginHint"] || body["login_hint"]
280
308
  })
@@ -285,6 +313,10 @@ module BetterAuth
285
313
 
286
314
  def self.social_user_from_id_token!(ctx, provider, id_token)
287
315
  token = fetch_value(id_token, "token").to_s
316
+ unless provider_callable(provider, :verify_id_token)
317
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["ID_TOKEN_NOT_SUPPORTED"])
318
+ end
319
+
288
320
  valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
289
321
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
290
322
 
@@ -301,6 +333,7 @@ module BetterAuth
301
333
  user = user_info[:user] || user_info["user"] if user_info
302
334
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user
303
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"))
304
337
 
305
338
  {
306
339
  user: user,
@@ -315,51 +348,105 @@ module BetterAuth
315
348
  }
316
349
  end
317
350
 
318
- 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)
319
352
  email = fetch_value(user_info, "email").to_s.downcase
320
353
  account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s
321
- 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
322
362
 
323
363
  if existing && existing[:linked_account]
324
364
  user = existing[:user]
325
365
  if ctx.context.options.account[:update_account_on_sign_in] != false
326
366
  update_data = account_storage_fields(account_info)
327
- 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
328
375
  end
329
376
  verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
330
377
  user = verified_user if verified_user
331
378
  new_user = false
332
379
  elsif existing
333
- unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
334
- 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"}
335
383
  end
336
384
  user = existing[:user]
337
- ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
338
- verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
339
- 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
340
401
  new_user = false
341
402
  else
342
403
  return {error: "signup disabled"} if disable_sign_up
343
404
 
344
- created = ctx.context.internal_adapter.create_oauth_user(
345
- {
346
- email: email,
347
- name: fetch_value(user_info, "name").to_s,
348
- image: fetch_value(user_info, "image"),
349
- emailVerified: !!fetch_value(user_info, "emailVerified")
350
- },
351
- account_info.merge("providerId" => provider_id, "accountId" => account_id),
352
- context: ctx
353
- )
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
354
422
  user = created[:user]
355
423
  new_user = true
356
424
  end
357
- 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
358
429
 
359
430
  session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
360
431
  {session: session, user: user, new_user: new_user}
361
432
  end
362
433
 
434
+ def self.store_oauth_state_cookie(ctx, state)
435
+ return unless ctx.request
436
+
437
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
438
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
439
+ end
440
+
441
+ def self.valid_oauth_state_cookie?(ctx, state)
442
+ return true unless ctx.request
443
+
444
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
445
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
446
+ Cookies.expire_cookie(ctx, cookie)
447
+ stored == state
448
+ end
449
+
363
450
  def self.oauth_error_url(base_url, error, description = nil)
364
451
  uri = URI.parse(base_url.to_s)
365
452
  query = URI.decode_www_form(uri.query.to_s)
@@ -377,17 +464,32 @@ module BetterAuth
377
464
  !!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
378
465
  end
379
466
 
380
- 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)
381
468
  linking = ctx.context.options.account[:account_linking] || {}
382
469
  return false if linking[:enabled] == false
383
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
384
474
 
385
475
  trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
386
476
  trusted || !!fetch_value(user_info, "emailVerified")
387
477
  end
388
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
+
389
490
  def self.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link)
390
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"))
391
493
 
392
494
  email = fetch_value(user_info, "email").to_s.downcase
393
495
  link_email = fetch_value(link, "email").to_s.downcase
@@ -403,20 +505,21 @@ module BetterAuth
403
505
  "userId" => user_id
404
506
  )
405
507
  existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id)
406
- if existing
407
- 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
408
511
 
409
- ctx.context.internal_adapter.update_account(existing["id"], account_info)
410
- else
411
- 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"}
412
520
  end
413
521
 
414
- if ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
415
- ctx.context.internal_adapter.update_user(user_id, {
416
- name: fetch_value(user_info, "name"),
417
- image: fetch_value(user_info, "image")
418
- }.compact)
419
- end
522
+ update_social_user_info_on_link(ctx, user_id, user_info)
420
523
  update_verified_email_on_link(ctx, user_id, link_email, user_info)
421
524
 
422
525
  {status: true}
@@ -434,13 +537,33 @@ module BetterAuth
434
537
  else
435
538
  !!fetch_value(user_info, "emailVerified")
436
539
  end
437
- update = {
540
+ provider_fields = Schema.parse_provider_profile_user_input(ctx.context.options, user_info, action: :update)
541
+ update = provider_fields.merge(
438
542
  "email" => email,
439
543
  "name" => fetch_value(user_info, "name").to_s,
440
544
  "image" => fetch_value(user_info, "image"),
441
545
  "emailVerified" => email_verified
442
- }.reject { |_key, value| value.nil? }
443
- 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
444
567
  end
445
568
 
446
569
  def self.safe_additional_state(body)
@@ -479,6 +602,17 @@ module BetterAuth
479
602
  ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true})
480
603
  end
481
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
+
482
616
  def self.parse_json_hash(value)
483
617
  return value if value.is_a?(Hash)
484
618
  return {} if value.nil? || value.to_s.empty?
@@ -488,5 +622,14 @@ module BetterAuth
488
622
  rescue JSON::ParserError
489
623
  {}
490
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
491
634
  end
492
635
  end
@@ -80,14 +80,16 @@ module BetterAuth
80
80
  current_password = body["currentPassword"] || body["current_password"]
81
81
  validate_password_length!(new_password, ctx.context.options.email_and_password)
82
82
  account = credential_account(ctx, session[:user]["id"])
83
- unless account && account["password"] && verify_password_value(ctx, current_password.to_s, account["password"])
83
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
84
+
85
+ unless verify_password_value(ctx, current_password.to_s, account["password"])
84
86
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
85
87
  end
86
88
 
87
89
  ctx.context.internal_adapter.update_account(account["id"], password: hash_password(ctx, new_password))
88
90
  token = nil
89
91
  if body["revokeOtherSessions"] || body["revoke_other_sessions"]
90
- ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
92
+ ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
91
93
  new_session = ctx.context.internal_adapter.create_session(session[:user]["id"])
92
94
  Cookies.set_session_cookie(ctx, {session: new_session, user: session[:user]})
93
95
  token = new_session["token"]
@@ -174,9 +176,12 @@ module BetterAuth
174
176
  session = current_session(ctx, sensitive: true)
175
177
  body = normalize_hash(ctx.body)
176
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)
177
180
  if body["password"]
178
181
  account = credential_account(ctx, session[:user]["id"])
179
- unless account && account["password"] && verify_password_value(ctx, body["password"], account["password"])
182
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
183
+
184
+ unless verify_password_value(ctx, body["password"], account["password"])
180
185
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
181
186
  end
182
187
  end
@@ -187,7 +192,7 @@ module BetterAuth
187
192
  token = SecureRandom.hex(16)
188
193
  expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
189
194
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/"
190
- 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)}"
191
196
  ctx.context.internal_adapter.create_verification_value(
192
197
  identifier: "delete-account-#{token}",
193
198
  value: session[:user]["id"],
@@ -294,7 +299,7 @@ module BetterAuth
294
299
  }
295
300
  ) do |ctx|
296
301
  enabled = ctx.context.options.user.dig(:change_email, :enabled)
297
- 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
298
303
  session = current_session(ctx, sensitive: true)
299
304
  body = normalize_hash(ctx.body)
300
305
  new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
@@ -308,6 +313,7 @@ module BetterAuth
308
313
  unless can_update_without_verification || can_send_confirmation || can_send_verification
309
314
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"])
310
315
  end
316
+ ctx.context.token_link_base_url if can_send_confirmation || can_send_verification
311
317
 
312
318
  existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
313
319
  next ctx.json({status: true}) if existing_target
@@ -322,6 +328,7 @@ module BetterAuth
322
328
  if can_send_confirmation
323
329
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
324
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)
325
332
  url = email_verification_url(ctx, token, callback_url)
326
333
  confirmation_sender.call({user: session[:user], new_email: new_email, url: url, token: token}, ctx.request)
327
334
  next ctx.json({status: true})
@@ -334,20 +341,20 @@ module BetterAuth
334
341
 
335
342
  def self.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url)
336
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)
337
345
  sender.call({user: user.merge("email" => new_email), url: email_verification_url(ctx, token, callback_url), token: token}, ctx.request)
338
346
  end
339
347
 
340
348
  def self.email_verification_url(ctx, token, callback_url)
341
349
  callback = URI.encode_www_form_component(callback_url || "/")
342
- "#{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}"
343
351
  end
344
352
 
345
353
  def self.delete_user_by_token!(ctx, session, token)
346
- verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
347
- 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"]
348
356
  raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"])
349
357
  end
350
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
351
358
  end
352
359
 
353
360
  def self.delete_current_user!(ctx, session)
@@ -356,7 +363,7 @@ module BetterAuth
356
363
  deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"])
357
364
  raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false
358
365
 
359
- ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
366
+ ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
360
367
  Cookies.delete_session_cookie(ctx)
361
368
  call_option(config[:after_delete], session[:user], ctx.request)
362
369
  end