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
@@ -8,24 +8,60 @@ module BetterAuth
8
8
 
9
9
  module_function
10
10
 
11
- def client_ip(request, options)
11
+ def client_ip(source, options)
12
12
  ip_options = options.advanced[:ip_address] || {}
13
13
  return nil if ip_options[:disable_ip_tracking]
14
14
 
15
- Array(ip_options[:ip_address_headers] || ["x-forwarded-for"]).each do |header|
16
- value = header_value(request, header)
17
- next unless value.is_a?(String)
18
-
19
- ip = value.split(",").first.to_s.strip
20
- return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
15
+ request = unwrap_request(source)
16
+ configured_headers = ip_options[:ip_address_headers]
17
+ if configured_headers
18
+ Array(configured_headers).each do |header|
19
+ value = header_value(source, header)
20
+ next unless value.is_a?(String)
21
+
22
+ ip = ip_from_header(
23
+ value,
24
+ trusted_proxies: ip_options[:trusted_proxies],
25
+ peer_ip: peer_ip(request),
26
+ ipv6_subnet: ip_options[:ipv6_subnet]
27
+ )
28
+ return ip if ip
29
+ end
21
30
  end
22
31
 
23
- ip = fallback_ip(request)
32
+ ip = fallback_ip(request, allow_forwarded_headers: configured_headers.nil?)
24
33
  return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
25
34
 
26
35
  LOCALHOST_IP if test_or_development?
27
36
  end
28
37
 
38
+ def ip_from_header(value, trusted_proxies: nil, peer_ip: nil, ipv6_subnet: nil)
39
+ forwarded_ips = value.split(",").map(&:strip).reject(&:empty?)
40
+ return nil if forwarded_ips.empty?
41
+
42
+ proxy_entries = Array(trusted_proxies).map(&:to_s).reject(&:empty?)
43
+ if proxy_entries.any?
44
+ proxies = proxy_entries.map { |entry| parse_trusted_proxy(entry) }
45
+ return nil if proxies.any?(&:nil?)
46
+
47
+ peer_address = parse_ip(peer_ip)
48
+ return nil unless peer_address && proxies.any? { |proxy| proxy.include?(native_address(peer_address)) }
49
+
50
+ forwarded_ips.reverse_each do |ip|
51
+ address = parse_ip(ip)
52
+ return nil unless address
53
+ next if proxies.any? { |proxy| proxy.include?(native_address(address)) }
54
+
55
+ return normalize_ip(ip, ipv6_subnet: ipv6_subnet)
56
+ end
57
+ return nil
58
+ end
59
+
60
+ return nil unless forwarded_ips.one? && valid_ip?(forwarded_ips.first)
61
+
62
+ normalize_ip(forwarded_ips.first, ipv6_subnet: ipv6_subnet)
63
+ end
64
+
29
65
  def header_value(request, header)
30
66
  return request.get_header(rack_header_name(header)) if request.respond_to?(:get_header)
31
67
  return request.headers[header.to_s.downcase] if request.respond_to?(:headers)
@@ -34,12 +70,55 @@ module BetterAuth
34
70
  nil
35
71
  end
36
72
 
37
- def fallback_ip(request)
38
- return request.ip.to_s if request.respond_to?(:ip)
73
+ def fallback_ip(request, allow_forwarded_headers:)
74
+ return nil unless request
75
+
76
+ remote_ip = request.remote_ip.to_s if request.respond_to?(:remote_ip)
77
+ return remote_ip if valid_ip?(remote_ip)
78
+
79
+ direct_peer_ip = peer_ip(request)
80
+ return nil unless valid_ip?(direct_peer_ip)
81
+
82
+ if allow_forwarded_headers && request.respond_to?(:ip)
83
+ ip = request.ip.to_s
84
+ return ip if valid_ip?(ip)
85
+ end
86
+
87
+ direct_peer_ip
88
+ end
89
+
90
+ def peer_ip(request)
91
+ if request.respond_to?(:get_header)
92
+ value = request.get_header("REMOTE_ADDR")
93
+ return value.to_s unless value.nil?
94
+ end
95
+ if request.respond_to?(:env)
96
+ value = request.env["REMOTE_ADDR"]
97
+ return value.to_s unless value.nil?
98
+ end
99
+ if request.is_a?(Hash)
100
+ value = request["REMOTE_ADDR"] || request[:REMOTE_ADDR] || request[:remote_addr]
101
+ return value.to_s unless value.nil?
102
+ end
39
103
 
40
104
  nil
41
105
  end
42
106
 
107
+ def unwrap_request(source)
108
+ current = source
109
+ seen = {}
110
+ while current&.respond_to?(:request)
111
+ break if seen[current.object_id]
112
+
113
+ seen[current.object_id] = true
114
+ nested = current.request
115
+ break unless nested && !nested.equal?(current)
116
+
117
+ current = nested
118
+ end
119
+ current
120
+ end
121
+
43
122
  def rack_header_name(header)
44
123
  "HTTP_#{header.to_s.upcase.tr("-", "_")}"
45
124
  end
@@ -47,10 +126,41 @@ module BetterAuth
47
126
  def valid_ip?(ip)
48
127
  return false if ip.to_s.empty? || ip.to_s.match?(/\s/)
49
128
 
129
+ !parse_ip(ip).nil?
130
+ end
131
+
132
+ def parse_ip(ip)
50
133
  IPAddr.new(ip)
51
- true
52
- rescue ArgumentError
53
- false
134
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
135
+ nil
136
+ end
137
+
138
+ def parse_trusted_proxy(entry)
139
+ value = entry.to_s
140
+ return nil if value.empty? || value.match?(/\s/)
141
+
142
+ if value.include?("/")
143
+ address, prefix = value.split("/", -1)
144
+ return nil unless value.count("/") == 1 && prefix.match?(/\A\d+\z/)
145
+
146
+ parsed_address = parse_ip(address)
147
+ return nil unless parsed_address
148
+
149
+ max_prefix = parsed_address.ipv4? ? 32 : 128
150
+ return nil if prefix.to_i > max_prefix
151
+ end
152
+
153
+ IPAddr.new(value)
154
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
155
+ nil
156
+ end
157
+
158
+ def native_address(address)
159
+ if address.respond_to?(:ipv4_mapped?) && address.ipv4_mapped?
160
+ address.native
161
+ else
162
+ address
163
+ end
54
164
  end
55
165
 
56
166
  def normalize_ip(ip, ipv6_subnet: nil)
@@ -75,12 +75,12 @@ module BetterAuth
75
75
 
76
76
  return run_on_response_chain(not_found) if disabled_path?(route_path)
77
77
 
78
- request = run_on_request_chain(request)
79
- return run_on_response_chain(request) if rack_response?(request)
80
-
81
78
  response = rate_limiter.call(request, context, route_path)
82
79
  return run_on_response_chain(response) if response
83
80
 
81
+ request = run_on_request_chain(request)
82
+ return run_on_response_chain(request) if rack_response?(request)
83
+
84
84
  endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params, endpoint)
85
85
  result = API.new(context, endpoints).execute(endpoint, endpoint_context)
86
86
  response = result.response.is_a?(APIError) ? error_response(result.response, headers: result.headers) : result.to_rack_response
@@ -118,6 +118,8 @@ module BetterAuth
118
118
  ) do |ctx|
119
119
  session = current_session(ctx, allow_nil: true)
120
120
  body = normalize_hash(ctx.body)
121
+ raise APIError.new("UNAUTHORIZED") if ctx.request && !session
122
+
121
123
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
122
124
  raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
123
125
 
@@ -174,6 +176,8 @@ module BetterAuth
174
176
  ) do |ctx|
175
177
  session = current_session(ctx, allow_nil: true)
176
178
  body = normalize_hash(ctx.body)
179
+ raise APIError.new("UNAUTHORIZED") if ctx.request && !session
180
+
177
181
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
178
182
  raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
179
183
 
@@ -226,6 +230,18 @@ module BetterAuth
226
230
  in: "query",
227
231
  required: false,
228
232
  schema: {type: "string"}
233
+ },
234
+ {
235
+ name: "providerId",
236
+ in: "query",
237
+ required: false,
238
+ schema: {type: "string"}
239
+ },
240
+ {
241
+ name: "userId",
242
+ in: "query",
243
+ required: false,
244
+ schema: {type: "string"}
229
245
  }
230
246
  ],
231
247
  responses: {
@@ -256,23 +272,43 @@ module BetterAuth
256
272
  }
257
273
  }
258
274
  ) do |ctx|
259
- session = current_session(ctx)
275
+ session = current_session(ctx, allow_nil: true)
276
+ raise APIError.new("UNAUTHORIZED") if !session && (ctx.request || !ctx.headers.empty?)
277
+
278
+ user_id = session&.dig(:user, "id") || fetch_value(ctx.query, "userId")
279
+ raise APIError.new("BAD_REQUEST", code: "USER_ID_OR_SESSION_REQUIRED", message: "Either userId or session is required") if user_id.to_s.empty?
280
+
260
281
  account_id = fetch_value(ctx.query, "accountId")
261
- account = if account_id
262
- ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
263
- entry["id"] == account_id || entry["accountId"] == account_id
282
+ provider_id = fetch_value(ctx.query, "providerId")
283
+ account = if !account_id.to_s.empty?
284
+ matching_accounts = ctx.context.internal_adapter.find_accounts(user_id).select do |entry|
285
+ entry["accountId"] == account_id && (provider_id.to_s.empty? || entry["providerId"] == provider_id)
264
286
  end
287
+ if matching_accounts.length > 1
288
+ raise APIError.new(
289
+ "BAD_REQUEST",
290
+ code: "AMBIGUOUS_ACCOUNT",
291
+ message: "Multiple accounts share this account ID. Pass a providerId to disambiguate."
292
+ )
293
+ end
294
+ matching_accounts.first
265
295
  else
266
- account_cookie(ctx, nil, nil, session[:user]["id"])
296
+ account_cookie(ctx, provider_id, nil, user_id)
267
297
  end
268
- raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
298
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless account && account["userId"] == user_id
269
299
 
270
300
  provider = social_provider(ctx.context, account["providerId"])
271
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
301
+ unless provider && provider_callable(provider, :get_user_info)
302
+ raise APIError.new(
303
+ "BAD_REQUEST",
304
+ code: "PROVIDER_NOT_CONFIGURED",
305
+ message: "Account is not associated with a configured social provider."
306
+ )
307
+ end
272
308
 
273
309
  tokens = access_token_response(
274
310
  ctx,
275
- user_id: session[:user]["id"],
311
+ user_id: user_id,
276
312
  provider_id: account["providerId"],
277
313
  account_id: account["accountId"],
278
314
  provider: provider
@@ -29,6 +29,7 @@ module BetterAuth
29
29
  ) do |ctx|
30
30
  sender = ctx.context.options.email_verification[:send_verification_email]
31
31
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
32
+ ctx.context.token_link_base_url
32
33
 
33
34
  body = normalize_hash(ctx.body)
34
35
  email = body["email"].to_s.downcase
@@ -42,16 +43,37 @@ module BetterAuth
42
43
  next ctx.json({status: true})
43
44
  end
44
45
 
46
+ start = verification_email_monotonic_clock
45
47
  found = ctx.context.internal_adapter.find_user_by_email(email)
46
48
  if found && !found[:user]["emailVerified"]
47
- send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
49
+ begin
50
+ send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
51
+ rescue => error
52
+ enforce_verification_email_floor(start)
53
+ raise error
54
+ end
48
55
  else
49
56
  create_email_verification_token(ctx, email)
50
57
  end
58
+ enforce_verification_email_floor(start)
59
+
51
60
  ctx.json({status: true})
52
61
  end
53
62
  end
54
63
 
64
+ def self.verification_email_monotonic_clock
65
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ end
67
+
68
+ def self.verification_email_sleep(seconds)
69
+ sleep(seconds)
70
+ end
71
+
72
+ def self.enforce_verification_email_floor(start)
73
+ remaining = 0.5 - (verification_email_monotonic_clock - start)
74
+ verification_email_sleep(remaining) if remaining.positive?
75
+ end
76
+
55
77
  def self.verify_email
56
78
  Endpoint.new(
57
79
  path: "/verify-email",
@@ -93,8 +115,10 @@ module BetterAuth
93
115
  callback_url = fetch_value(ctx.query, "callbackURL")
94
116
  validate_callback_url!(ctx.context, callback_url)
95
117
  payload = verify_email_token(ctx, token, callback_url)
118
+ change_email_token_identifier = validate_change_email_token!(ctx, token, payload, callback_url)
96
119
  email = payload["email"].to_s.downcase
97
120
  update_to = payload["updateTo"] || payload["update_to"]
121
+ preflight_follow_up_verification_token_link!(ctx, payload, update_to)
98
122
  user_data = ctx.context.internal_adapter.find_user_by_email(email)
99
123
  return redirect_or_error(ctx, callback_url, "user_not_found") unless user_data
100
124
 
@@ -103,22 +127,25 @@ module BetterAuth
103
127
  session = current_session(ctx, allow_nil: true)
104
128
  return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email
105
129
 
130
+ consume_change_email_token!(ctx, change_email_token_identifier, callback_url)
106
131
  request_type = payload["requestType"] || payload["request_type"]
107
132
  case request_type
108
133
  when "change-email-confirmation"
109
134
  send_change_email_verification_payload(ctx, user, update_to, callback_url)
110
135
  next redirect_or_json(ctx, callback_url, {status: true})
111
136
  when "change-email-verification"
137
+ session_data = email_change_session_data(ctx, session, user)
112
138
  updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
113
139
  updated_user = updated || user.merge("email" => update_to, "emailVerified" => true)
114
140
  call_option(ctx.context.options.email_verification[:after_email_verification], updated_user, ctx.request)
115
- set_verified_session_cookie(ctx, updated_user)
141
+ Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
116
142
  next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
117
143
  else
144
+ session_data = email_change_session_data(ctx, session, user)
118
145
  updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
119
146
  updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
120
147
  send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
121
- set_verified_session_cookie(ctx, updated_user)
148
+ Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
122
149
  next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
123
150
  end
124
151
  end
@@ -139,7 +166,7 @@ module BetterAuth
139
166
  def self.send_verification_email_payload(ctx, user, callback_url)
140
167
  token = create_email_verification_token(ctx, user["email"])
141
168
  callback = URI.encode_www_form_component(callback_url || "/")
142
- url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
169
+ url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
143
170
  ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
144
171
  end
145
172
 
@@ -148,11 +175,20 @@ module BetterAuth
148
175
  return unless sender.respond_to?(:call)
149
176
 
150
177
  token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"})
178
+ register_change_email_token!(ctx, token)
151
179
  callback = URI.encode_www_form_component(callback_url || "/")
152
- url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
180
+ url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
153
181
  sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request)
154
182
  end
155
183
 
184
+ def self.preflight_follow_up_verification_token_link!(ctx, payload, update_to)
185
+ return unless update_to
186
+ return unless ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
187
+
188
+ request_type = payload["requestType"] || payload["request_type"]
189
+ ctx.context.token_link_base_url unless request_type == "change-email-verification"
190
+ end
191
+
156
192
  def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
157
193
  payload = {"email" => email.to_s.downcase}.merge(extra)
158
194
  payload["updateTo"] = update_to if update_to
@@ -168,6 +204,43 @@ module BetterAuth
168
204
  redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN")
169
205
  end
170
206
 
207
+ # Ruby hardening adaptation of upstream PR #9519. This is intentionally
208
+ # stricter than the pinned Better Auth v1.6.23 behavior, and hashes the JWT
209
+ # before storage so the verification table never contains the bearer token.
210
+ def self.register_change_email_token!(ctx, token)
211
+ ctx.context.internal_adapter.create_verification_value(
212
+ identifier: change_email_token_identifier(token),
213
+ value: "issued",
214
+ expiresAt: Time.now + (ctx.context.options.email_verification[:expires_in] || 3600)
215
+ )
216
+ end
217
+
218
+ def self.validate_change_email_token!(ctx, token, payload, callback_url)
219
+ update_to = payload["updateTo"] || payload["update_to"]
220
+ request_type = payload["requestType"] || payload["request_type"]
221
+ return unless update_to && ["change-email-confirmation", "change-email-verification"].include?(request_type)
222
+
223
+ identifier = change_email_token_identifier(token)
224
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
225
+ unless verification && !expired_time?(verification["expiresAt"])
226
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED")
227
+ end
228
+ identifier
229
+ end
230
+
231
+ def self.consume_change_email_token!(ctx, identifier, callback_url)
232
+ return unless identifier
233
+
234
+ verification = ctx.context.internal_adapter.consume_verification_value(identifier)
235
+ unless verification
236
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED")
237
+ end
238
+ end
239
+
240
+ def self.change_email_token_identifier(token)
241
+ "change-email:#{Crypto.sha256(token, encoding: :base64url)}"
242
+ end
243
+
171
244
  def self.redirect_or_error(ctx, callback_url, error, code: nil)
172
245
  if callback_url
173
246
  separator = callback_url.include?("?") ? "&" : "?"
@@ -184,10 +257,24 @@ module BetterAuth
184
257
 
185
258
  def self.set_verified_session_cookie(ctx, user)
186
259
  session = current_session(ctx, allow_nil: true)
187
- session_data = session ? session[:session] : ctx.context.internal_adapter.create_session(user["id"])
260
+ session_data = if session && session[:user]["id"] == user["id"]
261
+ session[:session]
262
+ else
263
+ ctx.context.internal_adapter.create_session(user["id"])
264
+ end
188
265
  Cookies.set_session_cookie(ctx, {session: session_data, user: user})
189
266
  end
190
267
 
268
+ def self.email_change_session_data(ctx, session, user)
269
+ return session[:session] if session && session[:user]["id"] == user["id"]
270
+
271
+ session_data = ctx.context.internal_adapter.create_session(user["id"])
272
+ unless session_data
273
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"])
274
+ end
275
+ session_data
276
+ end
277
+
191
278
  def self.call_option(callback, user, request)
192
279
  callback.call(user, request) if callback.respond_to?(:call)
193
280
  end
@@ -43,6 +43,7 @@ module BetterAuth
43
43
  unless sender.respond_to?(:call)
44
44
  raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"])
45
45
  end
46
+ token_link_base_url = ctx.context.token_link_base_url
46
47
 
47
48
  body = normalize_hash(ctx.body)
48
49
  email = body["email"].to_s.downcase
@@ -64,7 +65,7 @@ module BetterAuth
64
65
  )
65
66
 
66
67
  callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
67
- url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
68
+ url = "#{token_link_base_url}/reset-password/#{token}?callbackURL=#{callback}"
68
69
  begin
69
70
  sender.call({user: found[:user], url: url, token: token}, ctx.request)
70
71
  rescue => error
@@ -151,8 +152,8 @@ module BetterAuth
151
152
  password = body["newPassword"] || body["new_password"]
152
153
  validate_password_length!(password, ctx.context.options.email_and_password)
153
154
 
154
- verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
155
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification && !expired_time?(verification["expiresAt"])
155
+ verification = ctx.context.internal_adapter.consume_verification_value("reset-password:#{token}")
156
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification
156
157
 
157
158
  user_id = verification["value"]
158
159
  hashed = hash_password(ctx, password)
@@ -162,13 +163,11 @@ module BetterAuth
162
163
  else
163
164
  ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed)
164
165
  end
165
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
166
-
167
166
  if (callback = ctx.context.options.email_and_password[:on_password_reset])
168
167
  user = ctx.context.internal_adapter.find_user_by_id(user_id)
169
168
  callback.call({user: user}, ctx.request) if user
170
169
  end
171
- ctx.context.internal_adapter.delete_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
170
+ ctx.context.internal_adapter.delete_user_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
172
171
 
173
172
  ctx.json({status: true})
174
173
  end
@@ -179,6 +178,7 @@ module BetterAuth
179
178
  path: "/verify-password",
180
179
  method: "POST",
181
180
  metadata: {
181
+ scope: "server",
182
182
  openapi: {
183
183
  operationId: "verifyPassword",
184
184
  description: "Verify the current user's password",
@@ -56,7 +56,7 @@ module BetterAuth
56
56
  }
57
57
  }
58
58
  ) do |ctx|
59
- session = current_session(ctx)
59
+ session = current_session(ctx, sensitive: true, fresh: true)
60
60
  sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
61
61
  active = sessions
62
62
  .map { |entry| stringify_keys(entry) }
@@ -88,6 +88,11 @@ module BetterAuth
88
88
 
89
89
  update = body.merge("updatedAt" => Time.now)
90
90
  updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
91
+ if !updated && Session.stateful?(ctx)
92
+ Cookies.delete_session_cookie(ctx)
93
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
94
+ end
95
+
91
96
  merged = session[:session].merge(updated || update)
92
97
  Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
93
98
  ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
@@ -144,7 +149,7 @@ module BetterAuth
144
149
  }
145
150
  ) do |ctx|
146
151
  session = current_session(ctx, sensitive: true)
147
- ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
152
+ ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
148
153
  Cookies.delete_session_cookie(ctx)
149
154
  ctx.json({status: true})
150
155
  end
@@ -50,6 +50,7 @@ module BetterAuth
50
50
  remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
51
51
 
52
52
  validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
53
+ preflight_sign_in_token_link!(ctx)
53
54
 
54
55
  unless EMAIL_PATTERN.match?(email)
55
56
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
@@ -107,8 +108,16 @@ module BetterAuth
107
108
  expires_in: verification[:expires_in] || 3600
108
109
  )
109
110
  callback = URI.encode_www_form_component(callback_url || "/")
110
- url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
111
+ url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
111
112
  sender.call({user: user, url: url, token: token}, ctx.request)
112
113
  end
114
+
115
+ def self.preflight_sign_in_token_link!(ctx)
116
+ verification = ctx.context.options.email_verification
117
+ password_config = ctx.context.options.email_and_password
118
+ if password_config[:require_email_verification] && verification[:send_on_sign_in] && verification[:send_verification_email].respond_to?(:call)
119
+ ctx.context.token_link_base_url
120
+ end
121
+ end
113
122
  end
114
123
  end