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
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
+ require "monitor"
6
7
 
7
8
  module BetterAuth
8
9
  module Adapters
@@ -13,6 +14,8 @@ module BetterAuth
13
14
  @adapter = adapter
14
15
  @options = options
15
16
  @hooks = DatabaseHooks.new(adapter, options)
17
+ @verification_locks_guard = Mutex.new
18
+ @verification_locks = {}
16
19
  end
17
20
 
18
21
  def create_oauth_user(user, account, context: nil)
@@ -63,7 +66,7 @@ module BetterAuth
63
66
  return false if deleted == false
64
67
 
65
68
  hooks.delete_many([{field: "userId", value: user_id}], "account")
66
- delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
69
+ delete_user_sessions(user_id)
67
70
  deleted
68
71
  end
69
72
 
@@ -143,20 +146,24 @@ module BetterAuth
143
146
  hooks.delete([{field: "token", value: token}], "session")
144
147
  end
145
148
 
146
- def delete_sessions(user_id_or_tokens)
149
+ def delete_user_sessions(user_id)
147
150
  if secondary_storage
148
- if user_id_or_tokens.is_a?(Array)
149
- user_id_or_tokens.each { |token| secondary_storage.delete(token) }
150
- else
151
- active_session_entries(user_id_or_tokens).each { |entry| secondary_storage.delete(entry["token"]) }
152
- secondary_storage.delete(active_key(user_id_or_tokens))
153
- end
151
+ active_session_entries(user_id).each { |entry| secondary_storage.delete(entry["token"]) }
152
+ secondary_storage.delete(active_key(user_id))
153
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
154
+ end
155
+
156
+ hooks.delete_many([{field: "userId", value: user_id}], "session")
157
+ end
158
+
159
+ def delete_sessions(tokens)
160
+ session_tokens = Array(tokens)
161
+ if secondary_storage
162
+ session_tokens.each { |token| secondary_storage.delete(token) }
154
163
  return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
155
164
  end
156
165
 
157
- field = user_id_or_tokens.is_a?(Array) ? "token" : "userId"
158
- operator = user_id_or_tokens.is_a?(Array) ? "in" : nil
159
- hooks.delete_many([{field: field, value: user_id_or_tokens, operator: operator}], "session")
166
+ hooks.delete_many([{field: "token", value: session_tokens, operator: "in"}], "session")
160
167
  end
161
168
 
162
169
  def delete_accounts(user_id)
@@ -167,6 +174,26 @@ module BetterAuth
167
174
  hooks.delete([{field: "id", value: account_id}], "account")
168
175
  end
169
176
 
177
+ # Removes access established before control of an email address was proven.
178
+ # The user is intentionally left unverified until the caller completes the
179
+ # proof transition and may safely mint a replacement session.
180
+ def revoke_unproven_account_access(user_id)
181
+ adapter.transaction do
182
+ user = find_user_by_id(user_id)
183
+ next unless user && !user["emailVerified"]
184
+
185
+ find_accounts(user_id).each do |account|
186
+ next unless account["providerId"] == "credential"
187
+
188
+ deleted = delete_account(account.fetch("id"))
189
+ raise Error, "Credential account revocation was vetoed" if deleted == false
190
+ end
191
+
192
+ deleted_sessions = delete_user_sessions(user_id)
193
+ raise Error, "Session revocation was vetoed" if deleted_sessions == false
194
+ end
195
+ end
196
+
170
197
  def find_oauth_user(email, account_id, provider_id)
171
198
  account = find_account_with_user(account_id, provider_id)
172
199
  if account
@@ -277,6 +304,56 @@ module BetterAuth
277
304
  values.first
278
305
  end
279
306
 
307
+ def consume_verification_value(identifier)
308
+ identifiers = verification_identifiers(identifier)
309
+ consumed = if secondary_storage && !verification_store_in_database?
310
+ consume_secondary_verification(identifiers)
311
+ else
312
+ consume_database_verification(identifiers)
313
+ end
314
+
315
+ invalidate_secondary_verification(identifiers, consumed) if consumed && secondary_storage && verification_store_in_database?
316
+ return nil unless consumed
317
+
318
+ expires_at = normalize_time(consumed["expiresAt"] || consumed[:expiresAt])
319
+ (expires_at > Time.now) ? normalize_verification_dates(consumed) : nil
320
+ rescue ArgumentError, TypeError
321
+ nil
322
+ end
323
+
324
+ def reserve_verification_value(data)
325
+ payload = stringify_keys(data)
326
+ identifier = payload.fetch("identifier")
327
+ stored_identifier = processed_verification_identifier(identifier)
328
+ reservation_id = Crypto.sha256("reserve:#{identifier}", encoding: :base64url)
329
+ verification = timestamps.merge(
330
+ "id" => reservation_id,
331
+ "identifier" => stored_identifier,
332
+ "value" => payload.fetch("value"),
333
+ "expiresAt" => normalize_time(payload.fetch("expiresAt"))
334
+ )
335
+
336
+ if secondary_storage && !verification_store_in_database?
337
+ return reserve_secondary_verification(verification)
338
+ end
339
+
340
+ adapter.consume_one(
341
+ model: "verification",
342
+ where: [
343
+ {field: "id", value: reservation_id},
344
+ {field: "expiresAt", value: Time.now, operator: "lt"}
345
+ ]
346
+ )
347
+ created = adapter.create_if_absent(
348
+ model: "verification",
349
+ data: verification,
350
+ conflict_field: "id",
351
+ force_allow_id: true
352
+ )
353
+ store_reserved_verification(verification) if created && secondary_storage
354
+ created
355
+ end
356
+
280
357
  def delete_verification_value(id)
281
358
  if secondary_storage
282
359
  stored_identifier = secondary_storage.get(verification_id_key(id))
@@ -325,6 +402,106 @@ module BetterAuth
325
402
 
326
403
  private
327
404
 
405
+ def verification_identifiers(identifier)
406
+ stored_identifier = processed_verification_identifier(identifier)
407
+ storage_option = verification_storage_option(identifier)
408
+ if storage_option && storage_option.to_s != "plain" && stored_identifier != identifier.to_s
409
+ [stored_identifier, identifier.to_s]
410
+ else
411
+ [stored_identifier]
412
+ end
413
+ end
414
+
415
+ def consume_secondary_verification(identifiers)
416
+ identifiers.each do |stored_identifier|
417
+ key = verification_key(stored_identifier)
418
+ raw = if secondary_storage.respond_to?(:get_and_delete)
419
+ secondary_storage.get_and_delete(key)
420
+ elsif secondary_storage.respond_to?(:getAndDelete)
421
+ secondary_storage.getAndDelete(key)
422
+ else
423
+ raise Error, "Secondary storage must implement atomic `get_and_delete`/`getAndDelete`, or verification values must be database-backed"
424
+ end
425
+ consumed = begin
426
+ parsed = parse_storage(raw)
427
+ parsed ? normalize_verification_dates(parsed) : nil
428
+ rescue ArgumentError, TypeError
429
+ nil
430
+ end
431
+ next unless consumed
432
+
433
+ invalidate_secondary_verification(identifiers, consumed)
434
+ return consumed
435
+ end
436
+ nil
437
+ end
438
+
439
+ def reserve_secondary_verification(verification)
440
+ key = verification_key(verification.fetch("identifier"))
441
+ ttl_seconds = ttl(millis(verification.fetch("expiresAt")))
442
+ return false unless ttl_seconds.positive?
443
+
444
+ value = JSON.generate(normalize_verification_dates(verification))
445
+ created = if secondary_storage.respond_to?(:set_if_absent)
446
+ secondary_storage.set_if_absent(key, value, ttl_seconds)
447
+ elsif secondary_storage.respond_to?(:setIfAbsent)
448
+ secondary_storage.setIfAbsent(key, value, ttl_seconds)
449
+ else
450
+ raise Error, "Secondary storage must implement atomic `set_if_absent`/`setIfAbsent`, or verification values must be database-backed"
451
+ end
452
+ return false unless created
453
+ true
454
+ end
455
+
456
+ def consume_database_verification(identifiers)
457
+ identifiers.each do |stored_identifier|
458
+ consumed = with_verification_lock(verification_key(stored_identifier)) do
459
+ adapter.transaction do |transaction_adapter|
460
+ where = [{field: "identifier", value: stored_identifier}]
461
+ latest = transaction_adapter.find_many(
462
+ model: "verification",
463
+ where: where,
464
+ sort_by: {field: "createdAt", direction: "desc"},
465
+ limit: 1
466
+ ).first
467
+ next nil unless latest
468
+ next nil if verification_delete_vetoed?(latest)
469
+
470
+ row = transaction_adapter.consume_one(
471
+ model: "verification",
472
+ where: [{field: "id", value: latest.fetch("id")}]
473
+ )
474
+ next nil unless row
475
+
476
+ transaction_adapter.delete_many(model: "verification", where: where)
477
+ run_verification_delete_after(row)
478
+ row
479
+ end
480
+ end
481
+ return consumed if consumed
482
+ end
483
+ nil
484
+ end
485
+
486
+ def invalidate_secondary_verification(identifiers, consumed)
487
+ identifiers.each { |stored_identifier| secondary_storage.delete(verification_key(stored_identifier)) }
488
+ id = consumed && (consumed["id"] || consumed[:id])
489
+ secondary_storage.delete(verification_id_key(id)) if id
490
+ end
491
+
492
+ def verification_delete_vetoed?(entity)
493
+ hooks.send(:before_hooks, "verification", :delete).any? { |hook| hook.call(entity, nil) == false }
494
+ end
495
+
496
+ def run_verification_delete_after(entity)
497
+ hooks.send(:after_hooks, "verification", :delete).each { |hook| hook.call(entity, nil) }
498
+ end
499
+
500
+ def with_verification_lock(key)
501
+ lock = @verification_locks_guard.synchronize { @verification_locks[key] ||= Monitor.new }
502
+ lock.synchronize { yield }
503
+ end
504
+
328
505
  def secondary_storage
329
506
  options.secondary_storage
330
507
  end
@@ -527,6 +704,15 @@ module BetterAuth
527
704
  normalized
528
705
  end
529
706
 
707
+ def store_reserved_verification(verification)
708
+ normalized = normalize_verification_dates(verification)
709
+ ttl_seconds = ttl(millis(normalized["expiresAt"]))
710
+ return normalized unless ttl_seconds.positive?
711
+
712
+ secondary_storage.set(verification_key(normalized["identifier"]), JSON.generate(normalized), ttl_seconds)
713
+ normalized
714
+ end
715
+
530
716
  def read_verification(identifier)
531
717
  normalize_verification_dates(parse_storage(secondary_storage.get(verification_key(identifier))))
532
718
  end
@@ -58,6 +58,16 @@ module BetterAuth
58
58
  end
59
59
  end
60
60
  end
61
+
62
+ def join_limit(config)
63
+ value = config[:limit]
64
+ return 100 if value.nil?
65
+
66
+ parsed = Integer(value)
67
+ parsed.positive? ? parsed : 100
68
+ rescue ArgumentError, TypeError
69
+ 100
70
+ end
61
71
  end
62
72
  end
63
73
  end