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
@@ -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
@@ -230,6 +230,18 @@ module BetterAuth
230
230
  in: "query",
231
231
  required: false,
232
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"}
233
245
  }
234
246
  ],
235
247
  responses: {
@@ -260,23 +272,43 @@ module BetterAuth
260
272
  }
261
273
  }
262
274
  ) do |ctx|
263
- 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
+
264
281
  account_id = fetch_value(ctx.query, "accountId")
265
- account = if account_id
266
- ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
267
- 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)
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
+ )
268
293
  end
294
+ matching_accounts.first
269
295
  else
270
- account_cookie(ctx, nil, nil, session[:user]["id"])
296
+ account_cookie(ctx, provider_id, nil, user_id)
271
297
  end
272
- 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
273
299
 
274
300
  provider = social_provider(ctx.context, account["providerId"])
275
- 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
276
308
 
277
309
  tokens = access_token_response(
278
310
  ctx,
279
- user_id: session[:user]["id"],
311
+ user_id: user_id,
280
312
  provider_id: account["providerId"],
281
313
  account_id: account["accountId"],
282
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?("?") ? "&" : "?"
@@ -192,6 +265,16 @@ module BetterAuth
192
265
  Cookies.set_session_cookie(ctx, {session: session_data, user: user})
193
266
  end
194
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
+
195
278
  def self.call_option(callback, user, request)
196
279
  callback.call(user, request) if callback.respond_to?(:call)
197
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
@@ -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
@@ -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,