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::PASSKEY_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def passkey(*args)
8
- Kernel.require "better_auth/passkey"
9
- BetterAuth::Plugins.passkey(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/passkey"
12
-
13
- raise LoadError, "BetterAuth::Plugins.passkey requires the better_auth-passkey gem. Add `gem \"better_auth-passkey\"` and `require \"better_auth/passkey\"`."
9
+ def passkey(*args, &block)
10
+ call_external_plugin!(:passkey, *args, implementation_constant: :PASSKEY_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-passkey", entry: "lib/better_auth/passkey.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -110,7 +110,7 @@ module BetterAuth
110
110
 
111
111
  if config[:require_verification] && !found["phoneNumberVerified"]
112
112
  code = phone_number_generate_code(config)
113
- phone_number_store_code(ctx, config, phone_number, code)
113
+ phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call)
114
114
  phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
115
115
  raise APIError.new("UNAUTHORIZED", message: PHONE_NUMBER_ERROR_CODES["PHONE_NUMBER_NOT_VERIFIED"])
116
116
  end
@@ -170,7 +170,7 @@ module BetterAuth
170
170
  phone_number = body[:phone_number].to_s
171
171
  validate_phone_number!(config, phone_number)
172
172
  code = phone_number_generate_code(config)
173
- phone_number_store_code(ctx, config, phone_number, code)
173
+ phone_number_store_code(ctx, config, phone_number, code) unless config[:verify_otp].respond_to?(:call)
174
174
  phone_number_deliver_otp(config, {phone_number: phone_number, code: code}, ctx)
175
175
  ctx.json({message: "code sent"})
176
176
  end
@@ -280,7 +280,7 @@ module BetterAuth
280
280
  phone_number = body[:phone_number].to_s
281
281
  user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
282
282
  code = phone_number_generate_code(config)
283
- phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code)
283
+ phone_number_store_code(ctx, config, "#{phone_number}-request-password-reset", code) unless config[:verify_otp].respond_to?(:call)
284
284
 
285
285
  if user && config[:send_password_reset_otp].respond_to?(:call)
286
286
  config[:send_password_reset_otp].call({phone_number: phone_number, code: code}, ctx)
@@ -319,14 +319,13 @@ module BetterAuth
319
319
  otp = body[:otp].to_s
320
320
  new_password = body[:new_password]
321
321
 
322
- verification = phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp, consume: false)
323
322
  user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
324
323
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["UNEXPECTED_ERROR"]) unless user
325
324
 
326
325
  Routes.validate_password_length!(new_password, ctx.context.options.email_and_password)
326
+ phone_number_verify_code!(ctx, config, "#{phone_number}-request-password-reset", otp)
327
327
  ctx.context.internal_adapter.update_password(user["id"], Routes.hash_password(ctx, new_password))
328
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
329
- ctx.context.internal_adapter.delete_sessions(user["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
328
+ ctx.context.internal_adapter.delete_user_sessions(user["id"]) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
330
329
  ctx.json({status: true})
331
330
  end
332
331
  end
@@ -367,43 +366,52 @@ module BetterAuth
367
366
  def phone_number_verify_code!(ctx, config, identifier, code, consume: true)
368
367
  verifier = config[:verify_otp]
369
368
  if verifier.respond_to?(:call)
369
+ # Custom verify_otp owns single-use and expiry state completely.
370
370
  valid = verifier.call({phone_number: identifier.delete_suffix("-request-password-reset"), code: code}, ctx)
371
371
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless valid
372
372
 
373
- verification = ctx.context.internal_adapter.find_verification_value(identifier)
374
- ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume && verification
375
- return verification || true
373
+ return true
376
374
  end
377
375
 
378
- verification = ctx.context.internal_adapter.find_verification_value(identifier)
379
- raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless verification
376
+ existing = ctx.context.internal_adapter.find_verification_value(identifier)
377
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_NOT_FOUND"]) unless existing
380
378
 
381
- if Routes.expired_time?(verification["expiresAt"])
379
+ if Routes.expired_time?(existing["expiresAt"])
380
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
382
381
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["OTP_EXPIRED"])
383
382
  end
384
383
 
384
+ _, existing_attempts = phone_number_split_code(existing["value"])
385
+ if existing_attempts.to_i >= config[:allowed_attempts].to_i
386
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
387
+ raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
388
+ end
389
+
390
+ verification = ctx.context.internal_adapter.consume_verification_value(identifier)
391
+ raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"]) unless verification
392
+
385
393
  stored_code, attempts = phone_number_split_code(verification["value"])
386
394
  attempts_count = attempts.to_i
387
395
  if attempts_count >= config[:allowed_attempts].to_i
388
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
389
396
  raise APIError.new("FORBIDDEN", message: PHONE_NUMBER_ERROR_CODES["TOO_MANY_ATTEMPTS"])
390
397
  end
391
398
 
392
399
  unless stored_code == code
393
- ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored_code}:#{attempts_count + 1}")
400
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored_code}:#{attempts_count + 1}", expiresAt: verification["expiresAt"])
394
401
  raise APIError.new("BAD_REQUEST", message: PHONE_NUMBER_ERROR_CODES["INVALID_OTP"])
395
402
  end
396
403
 
397
- ctx.context.internal_adapter.delete_verification_value(verification["id"]) if consume
404
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: verification["value"], expiresAt: verification["expiresAt"]) unless consume
398
405
  verification
399
406
  end
400
407
 
401
408
  def phone_number_store_code(ctx, config, identifier, code)
402
409
  ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
410
+ expires_at = Time.now + config[:expires_in].to_i
403
411
  ctx.context.internal_adapter.create_verification_value(
404
412
  identifier: identifier,
405
413
  value: "#{code}:0",
406
- expiresAt: Time.now + config[:expires_in].to_i
414
+ expiresAt: expires_at
407
415
  )
408
416
  end
409
417
 
@@ -442,15 +450,5 @@ module BetterAuth
442
450
  def truthy?(value)
443
451
  value == true || value.to_s == "true"
444
452
  end
445
-
446
- def deep_merge_hashes(base, override)
447
- base.merge(override) do |_key, old_value, new_value|
448
- if old_value.is_a?(Hash) && new_value.is_a?(Hash)
449
- deep_merge_hashes(old_value, new_value)
450
- else
451
- new_value
452
- end
453
- end
454
- end
455
453
  end
456
454
  end
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::SCIM_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def scim(*args)
8
- Kernel.require "better_auth/scim"
9
- BetterAuth::Plugins.scim(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/scim"
12
-
13
- raise LoadError, "BetterAuth::Plugins.scim requires the better_auth-scim gem. Add `gem \"better_auth-scim\"` and `require \"better_auth/scim\"`."
9
+ def scim(*args, &block)
10
+ call_external_plugin!(:scim, *args, implementation_constant: :SCIM_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-scim", entry: "lib/better_auth/scim.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
+ require "time"
4
5
 
5
6
  module BetterAuth
6
7
  module Plugins
@@ -8,6 +9,8 @@ module BetterAuth
8
9
 
9
10
  SIWE_WALLET_PATTERN = /\A0[xX][a-fA-F0-9]{40}\z/
10
11
  SIWE_EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
12
+ SIWE_HEADER_PATTERN = /\A(?:([a-zA-Z][a-zA-Z0-9+.-]*):\/\/)?(\S+) wants you to sign in with your Ethereum account:\z/
13
+ SIWE_FIELD_PATTERN = /\A([A-Za-z ]+): (.*)\z/
11
14
 
12
15
  def siwe(options = {})
13
16
  config = normalize_hash(options)
@@ -16,29 +19,30 @@ module BetterAuth
16
19
  id: "siwe",
17
20
  schema: siwe_schema(config[:schema]),
18
21
  endpoints: {
19
- get_siwe_nonce: get_siwe_nonce_endpoint(config),
22
+ get_siwe_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/nonce", operation_id: "getSiweNonce"),
23
+ get_nonce: get_siwe_nonce_endpoint(config, path: "/siwe/get-nonce", operation_id: "getNonce"),
20
24
  verify_siwe_message: verify_siwe_message_endpoint(config)
21
25
  },
22
26
  options: config
23
27
  )
24
28
  end
25
29
 
26
- def get_siwe_nonce_endpoint(config)
30
+ def get_siwe_nonce_endpoint(config, path:, operation_id:)
27
31
  Endpoint.new(
28
- path: "/siwe/nonce",
32
+ path: path,
29
33
  method: "POST",
30
34
  body_schema: ->(body) { siwe_nonce_body(body) },
31
35
  metadata: {
32
36
  openapi: {
33
- operationId: "getSiweNonce",
37
+ operationId: operation_id,
34
38
  description: "Generate a nonce for Sign-In with Ethereum",
35
39
  requestBody: OpenAPI.json_request_body(
36
40
  OpenAPI.object_schema(
37
41
  {
42
+ address: {type: "string"},
38
43
  walletAddress: {type: "string"},
39
44
  chainId: {type: ["number", "string", "null"]}
40
- },
41
- required: ["walletAddress"]
45
+ }
42
46
  )
43
47
  ),
44
48
  responses: {
@@ -56,7 +60,7 @@ module BetterAuth
56
60
  }
57
61
  ) do |ctx|
58
62
  body = normalize_hash(ctx.body)
59
- wallet_address = siwe_normalize_wallet!(body[:wallet_address])
63
+ wallet_address = siwe_normalize_wallet!(body[:wallet_address] || body[:address])
60
64
  chain_id = siwe_chain_id(body[:chain_id])
61
65
  nonce_callback = config[:get_nonce]
62
66
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE nonce callback is required") unless nonce_callback.respond_to?(:call)
@@ -111,21 +115,21 @@ module BetterAuth
111
115
  body = normalize_hash(ctx.body)
112
116
  wallet_address = siwe_normalize_wallet!(body[:wallet_address])
113
117
  chain_id = siwe_chain_id(body[:chain_id])
114
- email = body[:email].to_s
118
+ email = body[:email].to_s.downcase
115
119
  anonymous = config.key?(:anonymous) ? config[:anonymous] : true
116
120
  raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
117
121
  raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)
118
122
 
119
- verification = ctx.context.internal_adapter.find_verification_value(siwe_identifier(wallet_address, chain_id))
120
- if !verification || siwe_expired_time?(verification["expiresAt"])
123
+ verification = ctx.context.internal_adapter.consume_verification_value(siwe_identifier(wallet_address, chain_id))
124
+ unless verification
121
125
  raise APIError.new("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE", message: "Unauthorized: Invalid or expired nonce")
122
126
  end
123
127
 
128
+ siwe_validate_message!(body[:message], config[:domain], wallet_address, chain_id, verification["value"])
129
+
124
130
  verified = siwe_verify_message(config, body, wallet_address, chain_id, verification["value"], ctx)
125
131
  raise APIError.new("UNAUTHORIZED", message: "Unauthorized: Invalid SIWE signature") unless verified
126
132
 
127
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
128
-
129
133
  user = siwe_find_user(ctx, wallet_address, chain_id)
130
134
  user ||= siwe_create_user(ctx, config, wallet_address, chain_id, email, anonymous)
131
135
  siwe_ensure_wallet_and_account(ctx, user, wallet_address, chain_id)
@@ -188,7 +192,12 @@ module BetterAuth
188
192
 
189
193
  def siwe_nonce_body(body)
190
194
  data = normalize_hash(body)
191
- siwe_normalize_wallet!(data[:wallet_address])
195
+ wallet_address_keys = [:wallet_address, :address].select { |key| data.key?(key) }
196
+ if wallet_address_keys.empty?
197
+ raise APIError.new("BAD_REQUEST", message: "walletAddress or address is required")
198
+ end
199
+
200
+ wallet_address_keys.each { |key| siwe_normalize_wallet!(data[key]) }
192
201
  data[:chain_id] = siwe_chain_id(data[:chain_id])
193
202
  data
194
203
  end
@@ -201,10 +210,11 @@ module BetterAuth
201
210
  siwe_normalize_wallet!(data[:wallet_address])
202
211
  data[:chain_id] = siwe_chain_id(data[:chain_id])
203
212
  anonymous = config.key?(:anonymous) ? config[:anonymous] : true
204
- email = data[:email].to_s
213
+ email = data[:email].to_s.downcase
205
214
  raise APIError.new("BAD_REQUEST", message: "Email is required when anonymous is disabled.") if anonymous == false && email.empty?
206
215
  raise APIError.new("BAD_REQUEST", message: "Invalid email address") if !email.empty? && !SIWE_EMAIL_PATTERN.match?(email)
207
216
 
217
+ data[:email] = email unless email.empty?
208
218
  data
209
219
  end
210
220
 
@@ -226,6 +236,101 @@ module BetterAuth
226
236
  "siwe:#{wallet_address}:#{chain_id}"
227
237
  end
228
238
 
239
+ def siwe_parse_message(message)
240
+ result = {}
241
+ lines = message.to_s.split(/\r?\n/)
242
+
243
+ if (header = SIWE_HEADER_PATTERN.match(lines[0].to_s))
244
+ result[:scheme] = header[1] if header[1]
245
+ result[:domain] = header[2]
246
+ end
247
+
248
+ address = lines[1].to_s.strip
249
+ result[:address] = address if SIWE_WALLET_PATTERN.match?(address)
250
+
251
+ lines.each do |line|
252
+ field = SIWE_FIELD_PATTERN.match(line)
253
+ next unless field
254
+
255
+ key = field[1]
256
+ value = field[2]
257
+ case key
258
+ when "URI"
259
+ result[:uri] = value
260
+ when "Version"
261
+ result[:version] = value
262
+ when "Chain ID"
263
+ parsed_chain_id = siwe_parse_message_chain_id(value)
264
+ result[:chain_id] = parsed_chain_id unless parsed_chain_id.nil?
265
+ when "Nonce"
266
+ result[:nonce] = value
267
+ when "Issued At"
268
+ result[:issued_at] = value
269
+ when "Expiration Time"
270
+ result[:expiration_time] = value
271
+ when "Not Before"
272
+ result[:not_before] = value
273
+ when "Request ID"
274
+ result[:request_id] = value
275
+ end
276
+ end
277
+
278
+ result
279
+ rescue
280
+ {}
281
+ end
282
+
283
+ def siwe_parse_message_chain_id(value)
284
+ number = Float(value)
285
+ number.to_i if number.finite? && number == number.to_i
286
+ rescue ArgumentError, TypeError, RangeError
287
+ nil
288
+ end
289
+
290
+ def siwe_normalize_domain(domain)
291
+ normalized = domain.to_s.strip.downcase.sub(/\A[a-z][a-z0-9+.-]*:\/\//, "")
292
+ path_start = normalized.index("/")
293
+ path_start ? normalized[0...path_start] : normalized
294
+ rescue
295
+ ""
296
+ end
297
+
298
+ def siwe_validate_message!(message, domain, wallet_address, chain_id, nonce)
299
+ parsed = siwe_parse_message(message)
300
+ matches = parsed[:nonce] == nonce &&
301
+ parsed[:address]&.downcase == wallet_address.downcase &&
302
+ parsed[:chain_id] == chain_id &&
303
+ parsed[:domain] && siwe_normalize_domain(parsed[:domain]) == siwe_normalize_domain(domain)
304
+
305
+ unless matches
306
+ raise siwe_unauthorized_error(
307
+ "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH",
308
+ "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID"
309
+ )
310
+ end
311
+
312
+ now = Time.now
313
+ expiration_time = siwe_parse_message_time(parsed[:expiration_time]) if parsed[:expiration_time]
314
+ if expiration_time && now >= expiration_time
315
+ raise siwe_unauthorized_error("UNAUTHORIZED_SIWE_MESSAGE_EXPIRED", "Unauthorized: SIWE message has expired")
316
+ end
317
+
318
+ not_before = siwe_parse_message_time(parsed[:not_before]) if parsed[:not_before]
319
+ if not_before && now < not_before
320
+ raise siwe_unauthorized_error("UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID", "Unauthorized: SIWE message is not yet valid")
321
+ end
322
+ end
323
+
324
+ def siwe_parse_message_time(value)
325
+ Time.parse(value.to_s)
326
+ rescue ArgumentError, TypeError, RangeError
327
+ nil
328
+ end
329
+
330
+ def siwe_unauthorized_error(status, message)
331
+ APIError.new("UNAUTHORIZED", code: status, message: message)
332
+ end
333
+
229
334
  def siwe_verify_message(config, body, wallet_address, chain_id, nonce, ctx)
230
335
  verifier = config[:verify_message]
231
336
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "SIWE verify_message callback is required") unless verifier.respond_to?(:call)
@@ -262,12 +367,17 @@ module BetterAuth
262
367
  end
263
368
 
264
369
  def siwe_create_user(ctx, config, wallet_address, _chain_id, email, anonymous)
265
- domain = config[:email_domain_name] || URI.parse(ctx.context.base_url).host || ctx.context.base_url
370
+ domain = config[:email_domain_name] || URI.parse(ctx.context.canonical_base_url).host || ctx.context.canonical_base_url
266
371
  lookup = config[:ens_lookup]
267
372
  ens = lookup.respond_to?(:call) ? normalize_hash(lookup.call(wallet_address: wallet_address) || {}) : {}
373
+ normalized_email = email.to_s.downcase
374
+ user_email = "#{wallet_address}@#{domain}".downcase
375
+ if anonymous == false && !normalized_email.empty? && !ctx.context.internal_adapter.find_user_by_email(normalized_email)
376
+ user_email = normalized_email
377
+ end
268
378
  ctx.context.internal_adapter.create_user(
269
379
  name: ens[:name] || wallet_address,
270
- email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}",
380
+ email: user_email,
271
381
  image: ens[:avatar] || "",
272
382
  context: ctx
273
383
  )
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::SSO_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def sso(*args)
8
- Kernel.require "better_auth/sso"
9
- BetterAuth::Plugins.sso(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/sso"
12
-
13
- raise LoadError, "BetterAuth::Plugins.sso requires the better_auth-sso gem. Add `gem \"better_auth-sso\"` and `require \"better_auth/sso\"`."
9
+ def sso(*args, &block)
10
+ call_external_plugin!(:sso, *args, implementation_constant: :SSO_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-sso", entry: "lib/better_auth/sso.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::STRIPE_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def stripe(*args)
8
- Kernel.require "better_auth/stripe"
9
- BetterAuth::Plugins.stripe(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/stripe"
12
-
13
- raise LoadError, "BetterAuth::Plugins.stripe requires the better_auth-stripe gem. Add `gem \"better_auth-stripe\"` and `require \"better_auth/stripe\"`."
9
+ def stripe(*args, &block)
10
+ call_external_plugin!(:stripe, *args, implementation_constant: :STRIPE_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-stripe", entry: "lib/better_auth/stripe.rb", &block)
14
11
  end
15
12
  end
16
13
  end