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
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::API_KEY_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def api_key(*args)
8
- Kernel.require "better_auth/api_key"
9
- BetterAuth::Plugins.api_key(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/api_key"
12
-
13
- raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`."
9
+ def api_key(*args, &block)
10
+ call_external_plugin!(:api_key, *args, implementation_constant: :API_KEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-api-key", entry: "lib/better_auth/api_key.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -50,7 +50,7 @@ module BetterAuth
50
50
  return unless signed_token && valid_signed_token?(ctx, signed_token)
51
51
 
52
52
  cookie_name = ctx.context.auth_cookies[:session_token].name
53
- cookie = [ctx.headers["cookie"], "#{cookie_name}=#{signed_token}"].compact.reject(&:empty?).join("; ")
53
+ cookie = Cookies.set_request_cookie(ctx.headers["cookie"], cookie_name, signed_token)
54
54
  {context: {headers: ctx.headers.merge("cookie" => cookie)}}
55
55
  end
56
56
 
@@ -86,37 +86,23 @@ module BetterAuth
86
86
  token.to_s
87
87
  end
88
88
 
89
- def bearer_session_cookie(line)
90
- first, *attributes = line.to_s.split(";").map(&:strip)
91
- name, value = first.split("=", 2)
92
- return unless name && value
93
-
94
- {
95
- name: name,
96
- value: value,
97
- attributes: attributes.each_with_object({}) do |attribute, result|
98
- key, attribute_value = attribute.split("=", 2)
99
- result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
100
- end
101
- }
102
- end
103
-
104
89
  def expired_bearer_cookie?(cookie)
105
90
  max_age = cookie[:attributes]["max-age"]
106
91
  max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0
107
92
  end
108
93
 
109
94
  def expose_auth_token(ctx)
110
- set_cookie = ctx.response_headers["set-cookie"].to_s
111
95
  token_name = ctx.context.auth_cookies[:session_token].name
112
- token = set_cookie.lines.filter_map do |line|
113
- cookie = bearer_session_cookie(line)
96
+ session_cookie = Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).reverse_each.filter_map do |line|
97
+ cookie = Cookies.parse_set_cookie(line)
114
98
  next unless cookie && cookie[:name] == token_name
115
- next if cookie[:value].empty? || expired_bearer_cookie?(cookie)
116
99
 
117
- cookie[:value]
100
+ cookie
118
101
  end.first
119
- return unless token
102
+ return unless session_cookie
103
+ return if session_cookie[:value].empty? || expired_bearer_cookie?(session_cookie)
104
+
105
+ token = session_cookie[:value]
120
106
 
121
107
  exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
122
108
  exposed << "set-auth-token"
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "timeout"
5
6
  require "uri"
6
7
 
7
8
  module BetterAuth
@@ -22,6 +23,7 @@ module BetterAuth
22
23
  "/sign-in/email",
23
24
  "/request-password-reset"
24
25
  ].freeze
26
+ CAPTCHA_VERIFY_TIMEOUT_SECONDS = 10
25
27
 
26
28
  CAPTCHA_SITE_VERIFY_URLS = {
27
29
  "cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
@@ -46,7 +48,11 @@ module BetterAuth
46
48
 
47
49
  def captcha_on_request(request, context, config)
48
50
  endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints])
49
- return nil unless endpoints.any? { |endpoint| request.path_info.include?(endpoint.to_s) || request.url.include?(endpoint.to_s) }
51
+ path = request.path_info.to_s
52
+ email_otp_path = "/sign-in/email-otp"
53
+ email_otp_is_explicit = Array(config[:endpoints]).map(&:to_s).include?(email_otp_path)
54
+ return nil if path.include?(email_otp_path) && !email_otp_is_explicit
55
+ return nil unless endpoints.any? { |endpoint| path.include?(endpoint.to_s) }
50
56
 
51
57
  raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty?
52
58
 
@@ -76,9 +82,13 @@ module BetterAuth
76
82
  min_score: config[:min_score],
77
83
  provider: provider
78
84
  }
79
- return captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) if config[:verifier].respond_to?(:call)
80
-
81
- captcha_http_verify(params)
85
+ Timeout.timeout(CAPTCHA_VERIFY_TIMEOUT_SECONDS) do
86
+ if config[:verifier].respond_to?(:call)
87
+ captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params)))
88
+ else
89
+ captcha_http_verify(params)
90
+ end
91
+ end
82
92
  end
83
93
 
84
94
  def captcha_verifier_params(params)
@@ -103,7 +113,7 @@ module BetterAuth
103
113
  else
104
114
  URI.encode_www_form(verifier[:payload])
105
115
  end
106
- response = HTTPClient.request(uri, request)
116
+ response = HTTPClient.request(uri, request, open_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS, read_timeout: CAPTCHA_VERIFY_TIMEOUT_SECONDS)
107
117
  raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)
108
118
 
109
119
  JSON.parse(response.body.to_s)
@@ -127,7 +137,15 @@ module BetterAuth
127
137
  return false unless result && result["success"]
128
138
 
129
139
  if config[:provider].to_s == "google-recaptcha" && result.key?("score")
130
- return result["score"].to_f >= (config[:min_score] || 0.5).to_f
140
+ return false if result["score"].to_f < (config[:min_score] || 0.5).to_f
141
+ end
142
+
143
+ if ["google-recaptcha", "cloudflare-turnstile"].include?(config[:provider].to_s)
144
+ expected_action = config[:expected_action].to_s
145
+ return false if !expected_action.empty? && result["action"] != expected_action
146
+
147
+ allowed_hostnames = Array(config[:allowed_hostnames]).map(&:to_s)
148
+ return false if allowed_hostnames.any? && !allowed_hostnames.include?(result["hostname"].to_s)
131
149
  end
132
150
 
133
151
  true
@@ -15,6 +15,7 @@ module BetterAuth
15
15
  "ACCESS_DENIED" => "Access denied",
16
16
  "INVALID_USER_CODE" => "Invalid user code",
17
17
  "DEVICE_CODE_ALREADY_PROCESSED" => "Device code already processed",
18
+ "DEVICE_CODE_NOT_CLAIMED" => "Device code has not been claimed by a verifying session; call `GET /device` with the `user_code` while signed in before approving or denying",
18
19
  "POLLING_TOO_FREQUENTLY" => "Polling too frequently",
19
20
  "USER_NOT_FOUND" => "User not found",
20
21
  "FAILED_TO_CREATE_SESSION" => "Failed to create session",
@@ -58,6 +59,7 @@ module BetterAuth
58
59
  OpenAPI.object_schema(
59
60
  {
60
61
  client_id: {type: "string", description: "OAuth client ID"},
62
+ user_id: {type: "string", description: "Optional user ID to pre-bind to the device code"},
61
63
  scope: {type: "string", description: "Requested scopes"}
62
64
  }
63
65
  )
@@ -85,6 +87,7 @@ module BetterAuth
85
87
  data: {
86
88
  "deviceCode" => device_code,
87
89
  "userCode" => user_code,
90
+ "userId" => body["user_id"].to_s.empty? ? nil : body["user_id"],
88
91
  "expiresAt" => Time.now + expires_in,
89
92
  "status" => "pending",
90
93
  "pollingInterval" => interval * 1000,
@@ -162,6 +165,18 @@ module BetterAuth
162
165
  ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
163
166
  raise device_authorization_error("BAD_REQUEST", "access_denied", DEVICE_AUTHORIZATION_ERROR_CODES["ACCESS_DENIED"])
164
167
  when "approved"
168
+ claimed = ctx.context.adapter.consume_one(
169
+ model: "deviceCode",
170
+ where: [
171
+ {field: "deviceCode", value: body["device_code"].to_s},
172
+ {field: "status", value: "approved"}
173
+ ]
174
+ )
175
+ unless claimed && claimed["userId"]
176
+ raise device_authorization_error("BAD_REQUEST", "invalid_grant", DEVICE_AUTHORIZATION_ERROR_CODES["INVALID_DEVICE_CODE"])
177
+ end
178
+
179
+ record = OAuthProtocol.stringify_keys(claimed)
165
180
  user = ctx.context.internal_adapter.find_user_by_id(record["userId"])
166
181
  raise device_authorization_error("INTERNAL_SERVER_ERROR", "server_error", DEVICE_AUTHORIZATION_ERROR_CODES["USER_NOT_FOUND"]) unless user
167
182
 
@@ -170,7 +185,6 @@ module BetterAuth
170
185
 
171
186
  session_data = {session: session, user: user}
172
187
  ctx.context.set_new_session(session_data) if ctx.context.respond_to?(:set_new_session)
173
- ctx.context.adapter.delete(model: "deviceCode", where: [{field: "id", value: record["id"]}])
174
188
  ctx.json({
175
189
  access_token: session["token"],
176
190
  token_type: "Bearer",
@@ -203,6 +217,21 @@ module BetterAuth
203
217
  record = OAuthProtocol.stringify_keys(record)
204
218
  raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now
205
219
 
220
+ session = Routes.current_session(ctx, allow_nil: true)
221
+ if session && !record["userId"] && record["status"] == "pending"
222
+ claimed = ctx.context.adapter.increment_one(
223
+ model: "deviceCode",
224
+ where: [
225
+ {field: "id", value: record["id"]},
226
+ {field: "status", value: "pending"},
227
+ {field: "userId", value: nil}
228
+ ],
229
+ increment: {},
230
+ set: {"userId" => session[:user]["id"]}
231
+ )
232
+ record = OAuthProtocol.stringify_keys(claimed) if claimed
233
+ end
234
+
206
235
  ctx.json({user_code: code, status: record["status"]})
207
236
  end
208
237
  end
@@ -218,9 +247,9 @@ module BetterAuth
218
247
  requestBody: OpenAPI.json_request_body(
219
248
  OpenAPI.object_schema(
220
249
  {
221
- user_code: {type: "string", description: "User code shown on the device"},
222
250
  userCode: {type: "string", description: "User code shown on the device"}
223
- }
251
+ },
252
+ required: ["userCode"]
224
253
  )
225
254
  ),
226
255
  responses: {
@@ -247,9 +276,9 @@ module BetterAuth
247
276
  requestBody: OpenAPI.json_request_body(
248
277
  OpenAPI.object_schema(
249
278
  {
250
- user_code: {type: "string", description: "User code shown on the device"},
251
279
  userCode: {type: "string", description: "User code shown on the device"}
252
- }
280
+ },
281
+ required: ["userCode"]
253
282
  )
254
283
  ),
255
284
  responses: {
@@ -274,15 +303,24 @@ module BetterAuth
274
303
  record = OAuthProtocol.stringify_keys(record)
275
304
  raise device_authorization_error("BAD_REQUEST", "expired_token", DEVICE_AUTHORIZATION_ERROR_CODES["EXPIRED_USER_CODE"]) if device_authorization_time(record["expiresAt"]) <= Time.now
276
305
  raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless record["status"] == "pending"
277
- if record["userId"] && record["userId"] != session[:user]["id"]
306
+ unless record["userId"]
307
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_NOT_CLAIMED"])
308
+ end
309
+ if record["userId"] != session[:user]["id"]
278
310
  raise device_authorization_error("FORBIDDEN", "access_denied", "You are not authorized to #{action} this device authorization")
279
311
  end
280
312
 
281
- ctx.context.adapter.update(
313
+ updated = ctx.context.adapter.increment_one(
282
314
  model: "deviceCode",
283
- where: [{field: "id", value: record["id"]}],
284
- update: {"status" => status, "userId" => record["userId"] || session[:user]["id"]}
315
+ where: [
316
+ {field: "id", value: record["id"]},
317
+ {field: "status", value: "pending"},
318
+ {field: "userId", value: session[:user]["id"]}
319
+ ],
320
+ increment: {},
321
+ set: {"status" => status, "userId" => session[:user]["id"]}
285
322
  )
323
+ raise device_authorization_error("BAD_REQUEST", "invalid_request", DEVICE_AUTHORIZATION_ERROR_CODES["DEVICE_CODE_ALREADY_PROCESSED"]) unless updated
286
324
  ctx.json({success: true})
287
325
  end
288
326
 
@@ -8,7 +8,7 @@ module BetterAuth
8
8
  config = normalize_hash(options)
9
9
  oauth_plugin = dub_oauth_plugin(config[:oauth])
10
10
  endpoints = {dub_link: dub_link_endpoint(oauth_plugin)}
11
- endpoints[:dub_o_auth2_callback] = oauth_plugin.endpoints.fetch(:o_auth2_callback) if oauth_plugin
11
+ endpoints[:dub_oauth2_callback] = oauth_plugin.endpoints.fetch(:oauth2_callback) if oauth_plugin
12
12
 
13
13
  Plugin.new(
14
14
  id: "dub",
@@ -73,7 +73,7 @@ module BetterAuth
73
73
  Routes.validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
74
74
 
75
75
  ctx.body = body.merge(provider_id: "dub", callback_url: callback_url)
76
- oauth_plugin.endpoints.fetch(:o_auth2_link_account).call(ctx)
76
+ oauth_plugin.endpoints.fetch(:oauth2_link_account).call(ctx)
77
77
  end
78
78
  end
79
79
 
@@ -29,7 +29,6 @@ module BetterAuth
29
29
  verify_email_otp: verify_email_otp_endpoint(config),
30
30
  sign_in_email_otp: sign_in_email_otp_endpoint(config),
31
31
  request_password_reset_email_otp: request_password_reset_email_otp_endpoint(config),
32
- forget_password_email_otp: forget_password_email_otp_endpoint(config),
33
32
  reset_password_email_otp: reset_password_email_otp_endpoint(config),
34
33
  request_email_change_email_otp: request_email_change_email_otp_endpoint(config),
35
34
  change_email_email_otp: change_email_email_otp_endpoint(config)
@@ -119,7 +118,7 @@ module BetterAuth
119
118
  end
120
119
 
121
120
  def create_verification_otp_endpoint(config)
122
- Endpoint.new(method: "POST") do |ctx|
121
+ Endpoint.new(method: "POST", metadata: {server_only: true}) do |ctx|
123
122
  body = normalize_hash(ctx.body)
124
123
  email = body[:email].to_s.downcase
125
124
  type = body[:type].to_s
@@ -133,9 +132,9 @@ module BetterAuth
133
132
 
134
133
  def get_verification_otp_endpoint(config)
135
134
  Endpoint.new(
136
- path: "/email-otp/get-verification-otp",
137
135
  method: "GET",
138
136
  metadata: {
137
+ server_only: true,
139
138
  openapi: {
140
139
  operationId: "getVerificationOTP",
141
140
  description: "Get a stored verification OTP when storage allows plaintext access",
@@ -326,7 +325,13 @@ module BetterAuth
326
325
  end
327
326
 
328
327
  unless user["emailVerified"]
329
- user = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
328
+ user = ctx.context.adapter.transaction do
329
+ ctx.context.internal_adapter.revoke_unproven_account_access(user["id"])
330
+ updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
331
+ raise Error, "Failed to verify email-OTP user" unless updated
332
+
333
+ updated
334
+ end
330
335
  end
331
336
 
332
337
  session = ctx.context.internal_adapter.create_session(user["id"])
@@ -453,32 +458,6 @@ module BetterAuth
453
458
  end
454
459
  end
455
460
 
456
- def forget_password_email_otp_endpoint(config)
457
- Endpoint.new(
458
- path: "/forget-password/email-otp",
459
- method: "POST",
460
- metadata: {
461
- openapi: {
462
- operationId: "forgetPasswordEmailOTP",
463
- description: "Request a password reset OTP by email",
464
- requestBody: OpenAPI.json_request_body(
465
- OpenAPI.object_schema(
466
- {
467
- email: {type: "string"}
468
- },
469
- required: ["email"]
470
- )
471
- ),
472
- responses: {
473
- "200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema)
474
- }
475
- }
476
- }
477
- ) do |ctx|
478
- email_otp_password_reset_request(ctx, config)
479
- end
480
- end
481
-
482
461
  def reset_password_email_otp_endpoint(config)
483
462
  Endpoint.new(
484
463
  path: "/email-otp/reset-password",
@@ -524,7 +503,7 @@ module BetterAuth
524
503
  ctx.context.internal_adapter.update_user(found[:user]["id"], emailVerified: true) unless found[:user]["emailVerified"]
525
504
  callback = ctx.context.options.email_and_password[:on_password_reset]
526
505
  callback.call({user: found[:user]}, ctx.request) if callback.respond_to?(:call)
527
- ctx.context.internal_adapter.delete_sessions(found[:user]["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
506
+ ctx.context.internal_adapter.delete_user_sessions(found[:user]["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
528
507
  ctx.json({success: true})
529
508
  end
530
509
  end
@@ -610,35 +589,40 @@ module BetterAuth
610
589
  end
611
590
 
612
591
  def email_otp_verify!(ctx, config, email:, type:, otp:, consume: true)
613
- verification = ctx.context.internal_adapter.find_verification_value(email_otp_identifier(email, type))
614
- raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification
592
+ identifier = email_otp_identifier(email, type)
593
+ existing = ctx.context.internal_adapter.find_verification_value(identifier)
615
594
 
616
- if Routes.expired_time?(verification["expiresAt"])
617
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
595
+ if existing && Routes.expired_time?(existing["expiresAt"])
596
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
618
597
  raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["OTP_EXPIRED"])
619
598
  end
620
599
 
600
+ verification = ctx.context.internal_adapter.consume_verification_value(identifier)
601
+ raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"]) unless verification
602
+
621
603
  otp_value, attempts = email_otp_split(verification["value"])
622
604
  attempts_count = attempts.to_i
623
605
  if attempts_count >= config[:allowed_attempts].to_i
624
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
625
606
  raise APIError.new("FORBIDDEN", message: EMAIL_OTP_ERROR_CODES["TOO_MANY_ATTEMPTS"])
626
607
  end
627
608
 
628
- ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume
629
609
  unless email_otp_matches?(ctx, config, otp_value, otp)
630
- if consume
631
- ctx.context.internal_adapter.create_verification_value(
632
- identifier: email_otp_identifier(email, type),
633
- value: "#{otp_value}:#{attempts_count + 1}",
634
- expiresAt: verification["expiresAt"]
635
- )
636
- else
637
- ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{otp_value}:#{attempts_count + 1}")
638
- end
610
+ ctx.context.internal_adapter.create_verification_value(
611
+ identifier: identifier,
612
+ value: "#{otp_value}:#{attempts_count + 1}",
613
+ expiresAt: verification["expiresAt"]
614
+ )
639
615
  raise APIError.new("BAD_REQUEST", message: EMAIL_OTP_ERROR_CODES["INVALID_OTP"])
640
616
  end
641
617
 
618
+ unless consume
619
+ ctx.context.internal_adapter.create_verification_value(
620
+ identifier: identifier,
621
+ value: verification["value"],
622
+ expiresAt: verification["expiresAt"]
623
+ )
624
+ end
625
+
642
626
  true
643
627
  end
644
628
 
@@ -763,7 +747,6 @@ module BetterAuth
763
747
  /sign-in/email-otp
764
748
  /email-otp/request-password-reset
765
749
  /email-otp/reset-password
766
- /forget-password/email-otp
767
750
  /email-otp/request-email-change
768
751
  /email-otp/change-email
769
752
  ].map do |path|