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
@@ -4,6 +4,7 @@ require "base64"
4
4
  require "json"
5
5
  require "openssl"
6
6
  require "securerandom"
7
+ require "time"
7
8
  require "uri"
8
9
 
9
10
  module BetterAuth
@@ -17,6 +18,7 @@ module BetterAuth
17
18
  "INVALID_BACKUP_CODE" => "Invalid backup code",
18
19
  "INVALID_CODE" => "Invalid code",
19
20
  "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE" => "Too many attempts. Please request a new code.",
21
+ "ACCOUNT_TEMPORARILY_LOCKED" => "Too many failed verification attempts. Your account is temporarily locked. Please try again later.",
20
22
  "INVALID_TWO_FACTOR_COOKIE" => "Invalid two factor cookie"
21
23
  }.freeze
22
24
 
@@ -25,6 +27,9 @@ module BetterAuth
25
27
  TRUST_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
26
28
  TWO_FACTOR_COOKIE_MAX_AGE = 10 * 60
27
29
  TWO_FACTOR_MODEL = "twoFactor"
30
+ DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS = 5
31
+ DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS = 10
32
+ DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS = 15 * 60
28
33
 
29
34
  module_function
30
35
 
@@ -33,6 +38,11 @@ module BetterAuth
33
38
  two_factor_table: "twoFactor",
34
39
  trust_device_max_age: TRUST_DEVICE_COOKIE_MAX_AGE,
35
40
  two_factor_cookie_max_age: TWO_FACTOR_COOKIE_MAX_AGE,
41
+ account_lockout: {
42
+ enabled: true,
43
+ max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS,
44
+ duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS
45
+ },
36
46
  backup_code_options: {store_backup_codes: "encrypted"},
37
47
  otp_options: {},
38
48
  totp_options: {}
@@ -40,6 +50,11 @@ module BetterAuth
40
50
  config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
41
51
  config[:otp_options] = normalize_hash(config[:otp_options])
42
52
  config[:totp_options] = normalize_hash(config[:totp_options])
53
+ config[:account_lockout] = {
54
+ enabled: true,
55
+ max_failed_attempts: DEFAULT_ACCOUNT_LOCKOUT_MAX_FAILED_ATTEMPTS,
56
+ duration_seconds: DEFAULT_ACCOUNT_LOCKOUT_DURATION_SECONDS
57
+ }.merge(normalize_hash(config[:account_lockout]))
43
58
  config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless)
44
59
  config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless)
45
60
 
@@ -169,8 +184,22 @@ module BetterAuth
169
184
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"])
170
185
  end
171
186
 
172
- secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
173
- raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
187
+ sign_in = data[:session][:session].nil?
188
+ two_factor_assert_not_locked!(ctx, config, record) if sign_in
189
+ attempt = sign_in ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil
190
+ valid_code = begin
191
+ secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
192
+ two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
193
+ rescue
194
+ attempt&.fetch(:restore)&.call
195
+ raise
196
+ end
197
+ unless valid_code
198
+ attempt&.fetch(:record_failure)&.call
199
+ two_factor_record_failure!(ctx, config, record) if sign_in
200
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
201
+ end
202
+ two_factor_reset_failures!(ctx, config, record) if sign_in
174
203
 
175
204
  if record["verified"] != true
176
205
  if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
@@ -215,23 +244,34 @@ module BetterAuth
215
244
  Endpoint.new(path: "/two-factor/verify-otp", method: "POST", metadata: two_factor_openapi("verifyTwoFactorOTP", "Verify a two factor OTP", two_factor_verification_response_schema)) do |ctx|
216
245
  body = normalize_hash(ctx.body)
217
246
  data = two_factor_verification_context(ctx, config)
218
- verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}")
247
+ sign_in = data[:session][:session].nil?
248
+ record = nil
249
+ if sign_in
250
+ record = two_factor_record(ctx, config, data[:session][:user]["id"])
251
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record
252
+
253
+ two_factor_assert_not_locked!(ctx, config, record)
254
+ end
255
+ identifier = "2fa-otp-#{data[:key]}"
256
+ verification = ctx.context.internal_adapter.consume_verification_value(identifier)
219
257
  stored, counter = verification&.fetch("value", nil).to_s.split(":", 2)
220
- if !verification || verification["expiresAt"] < Time.now
221
- ctx.context.internal_adapter.delete_verification_value(verification["id"]) if verification
258
+ unless verification
222
259
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["OTP_HAS_EXPIRED"])
223
260
  end
224
261
 
225
262
  allowed = (config[:otp_options][:allowed_attempts] || 5).to_i
226
263
  if counter.to_i >= allowed
227
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
228
264
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"])
229
265
  end
230
266
 
231
267
  unless two_factor_otp_matches?(ctx, stored, body[:code].to_s, config[:otp_options])
232
- ctx.context.internal_adapter.update_verification_value(verification["id"], value: "#{stored}:#{counter.to_i + 1}")
268
+ rearmed = ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: "#{stored}:#{counter.to_i + 1}", expiresAt: verification["expiresAt"])
269
+ raise Error, "Failed to re-arm two-factor OTP attempt counter" unless rearmed
270
+
271
+ two_factor_record_failure!(ctx, config, record) if sign_in
233
272
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"])
234
273
  end
274
+ two_factor_reset_failures!(ctx, config, record) if sign_in
235
275
 
236
276
  if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
237
277
  updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
@@ -252,13 +292,28 @@ module BetterAuth
252
292
  record = two_factor_record(ctx, config, data[:session][:user]["id"])
253
293
  raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
254
294
 
255
- codes = two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])
295
+ sign_in = data[:session][:session].nil?
296
+ two_factor_assert_not_locked!(ctx, config, record) if sign_in
297
+ attempt = sign_in ? data[:begin_attempt].call(DEFAULT_TWO_FACTOR_ALLOWED_ATTEMPTS) : nil
298
+ codes = begin
299
+ two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])
300
+ rescue
301
+ attempt&.fetch(:restore)&.call
302
+ raise
303
+ end
256
304
  unless codes.include?(body[:code].to_s)
305
+ attempt&.fetch(:record_failure)&.call
306
+ two_factor_record_failure!(ctx, config, record) if sign_in
257
307
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
258
308
  end
259
309
 
260
310
  remaining = codes.reject { |code| code == body[:code].to_s }
261
- stored = two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options])
311
+ stored = begin
312
+ two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options])
313
+ rescue
314
+ attempt&.fetch(:restore)&.call
315
+ raise
316
+ end
262
317
  updated = ctx.context.adapter.update(
263
318
  model: TWO_FACTOR_MODEL,
264
319
  where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
@@ -266,6 +321,7 @@ module BetterAuth
266
321
  )
267
322
  raise APIError.new("CONFLICT", message: "Failed to verify backup code. Please try again.") unless updated
268
323
 
324
+ two_factor_reset_failures!(ctx, config, record) if sign_in
269
325
  body[:disable_session] ? ctx.json({token: data[:session][:session]&.fetch("token", nil), user: Schema.parse_output(ctx.context.options, "user", data[:session][:user])}) : data[:valid].call
270
326
  end
271
327
  end
@@ -300,6 +356,7 @@ module BetterAuth
300
356
  openapi: {
301
357
  operationId: operation_id,
302
358
  description: description,
359
+ requestBody: two_factor_request_body(operation_id),
303
360
  responses: {
304
361
  "200" => OpenAPI.json_response("Success", response_schema)
305
362
  }
@@ -307,6 +364,26 @@ module BetterAuth
307
364
  }
308
365
  end
309
366
 
367
+ def two_factor_request_body(operation_id)
368
+ schema = case operation_id
369
+ when "enableTwoFactor"
370
+ OpenAPI.object_schema({password: {type: "string"}, issuer: {type: "string"}})
371
+ when "disableTwoFactor", "getTOTPURI", "generateBackupCodes"
372
+ OpenAPI.object_schema({password: {type: "string"}})
373
+ when "generateTOTP"
374
+ OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"])
375
+ when "verifyTOTP", "verifyTwoFactorOTP"
376
+ OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}}, required: ["code"])
377
+ when "verifyBackupCode"
378
+ OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, trustDevice: {type: "boolean"}}, required: ["code"])
379
+ when "sendTwoFactorOTP"
380
+ OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
381
+ else
382
+ {type: "object", properties: {}}
383
+ end
384
+ OpenAPI.json_request_body(schema)
385
+ end
386
+
310
387
  def two_factor_enable_response_schema
311
388
  OpenAPI.object_schema(
312
389
  {
@@ -350,7 +427,9 @@ module BetterAuth
350
427
  secret: {type: "string", required: true, returned: false, index: true},
351
428
  backupCodes: {type: "string", required: true, returned: false},
352
429
  userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
353
- verified: {type: "boolean", required: false, default_value: true, input: false}
430
+ verified: {type: "boolean", required: false, default_value: true, input: false},
431
+ failedVerificationCount: {type: "number", required: false, default_value: 0, input: false, returned: false},
432
+ lockedUntil: {type: "date", required: false, input: false, returned: false}
354
433
  }
355
434
  }
356
435
  }
@@ -370,10 +449,16 @@ module BetterAuth
370
449
  ctx.context.internal_adapter.delete_session(data[:session]["token"])
371
450
  cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME, max_age: config[:two_factor_cookie_max_age])
372
451
  identifier = "2fa-#{Crypto.random_string(20)}"
452
+ expires_at = Time.now + config[:two_factor_cookie_max_age].to_i
373
453
  ctx.context.internal_adapter.create_verification_value(
374
454
  identifier: identifier,
375
455
  value: data[:user]["id"],
376
- expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
456
+ expiresAt: expires_at
457
+ )
458
+ ctx.context.internal_adapter.create_verification_value(
459
+ identifier: "2fa-attempts-#{identifier}",
460
+ value: "0",
461
+ expiresAt: expires_at
377
462
  )
378
463
  ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
379
464
  ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
@@ -383,7 +468,13 @@ module BetterAuth
383
468
  session = Routes.current_session(ctx, allow_nil: true)
384
469
  if session
385
470
  key = "#{session[:user]["id"]}!#{session[:session]["id"]}"
386
- return {session: session, key: key, valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", session[:user])}) }}
471
+ no_op_attempt = ->(_allowed_attempts) { {record_failure: -> {}, restore: -> {}} }
472
+ return {
473
+ session: session,
474
+ key: key,
475
+ valid: -> { ctx.json({token: session[:session]["token"], user: Schema.parse_output(ctx.context.options, "user", session[:user])}) },
476
+ begin_attempt: no_op_attempt
477
+ }
387
478
  end
388
479
 
389
480
  cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME)
@@ -397,11 +488,15 @@ module BetterAuth
397
488
  raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless user
398
489
 
399
490
  valid = lambda do
491
+ consumed = ctx.context.internal_adapter.consume_verification_value(identifier)
492
+ unless consumed && consumed["value"] == user["id"]
493
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"])
494
+ end
495
+
400
496
  dont_remember_me = Cookies.dont_remember?(ctx)
401
497
  new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me)
402
498
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session
403
499
 
404
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
405
500
  Cookies.set_session_cookie(ctx, {session: new_session, user: user}, dont_remember_me)
406
501
  Cookies.expire_cookie(ctx, cookie)
407
502
  if normalize_hash(ctx.body)[:trust_device]
@@ -411,7 +506,41 @@ module BetterAuth
411
506
  ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
412
507
  end
413
508
 
414
- {session: {session: nil, user: user}, key: identifier, valid: valid}
509
+ begin_attempt = lambda do |allowed_attempts|
510
+ attempts_identifier = "2fa-attempts-#{identifier}"
511
+ consumed = begin
512
+ ctx.context.internal_adapter.consume_verification_value(attempts_identifier)
513
+ rescue
514
+ nil
515
+ end
516
+ raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless consumed
517
+
518
+ raw_count = consumed["value"].to_s
519
+ attempts = raw_count.match?(/\A\d+\z/) ? raw_count.to_i : allowed_attempts
520
+ if attempts >= allowed_attempts
521
+ begin
522
+ ctx.context.internal_adapter.consume_verification_value(identifier)
523
+ rescue
524
+ nil
525
+ end
526
+ Cookies.expire_cookie(ctx, cookie)
527
+ raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE"])
528
+ end
529
+
530
+ rearm = lambda do |count|
531
+ created = ctx.context.internal_adapter.create_verification_value(
532
+ identifier: attempts_identifier,
533
+ value: count.to_s,
534
+ expiresAt: verification["expiresAt"]
535
+ )
536
+ raise Error, "Failed to re-arm two-factor attempt counter" unless created
537
+
538
+ created
539
+ end
540
+ {record_failure: -> { rearm.call(attempts + 1) }, restore: -> { rearm.call(attempts) }}
541
+ end
542
+
543
+ {session: {session: nil, user: user}, key: identifier, valid: valid, begin_attempt: begin_attempt}
415
544
  end
416
545
 
417
546
  def two_factor_set_trusted_device(ctx, config, user_id)
@@ -430,9 +559,10 @@ module BetterAuth
430
559
 
431
560
  token, identifier = value.split("!", 2)
432
561
  expected = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
433
- verification = identifier && ctx.context.internal_adapter.find_verification_value(identifier)
434
- if token && identifier && Crypto.constant_time_compare(token, expected) && verification && verification["value"] == user_id && verification["expiresAt"] > Time.now
435
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
562
+ verification = if token && identifier && Crypto.constant_time_compare(token, expected)
563
+ ctx.context.internal_adapter.consume_verification_value(identifier)
564
+ end
565
+ if verification && verification["value"] == user_id
436
566
  two_factor_set_trusted_device(ctx, config, user_id)
437
567
  true
438
568
  else
@@ -445,6 +575,64 @@ module BetterAuth
445
575
  ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
446
576
  end
447
577
 
578
+ def two_factor_assert_not_locked!(ctx, config, record)
579
+ lockout = config[:account_lockout]
580
+ return unless lockout && lockout[:enabled] != false && record["lockedUntil"]
581
+
582
+ locked_until = two_factor_lock_time(record["lockedUntil"])
583
+ if locked_until > Time.now
584
+ raise APIError.new("TOO_MANY_REQUESTS", message: TWO_FACTOR_ERROR_CODES["ACCOUNT_TEMPORARILY_LOCKED"])
585
+ end
586
+
587
+ cleared = ctx.context.adapter.increment_one(
588
+ model: TWO_FACTOR_MODEL,
589
+ where: [
590
+ {field: "id", value: record["id"]},
591
+ {field: "lockedUntil", operator: "lte", value: Time.now}
592
+ ],
593
+ increment: {},
594
+ set: {failedVerificationCount: 0, lockedUntil: nil}
595
+ )
596
+ raise Error, "Failed to clear expired two-factor account lock" unless cleared
597
+ end
598
+
599
+ def two_factor_record_failure!(ctx, config, record)
600
+ lockout = config[:account_lockout]
601
+ return unless lockout && lockout[:enabled] != false
602
+
603
+ updated = ctx.context.adapter.increment_one(
604
+ model: TWO_FACTOR_MODEL,
605
+ where: [{field: "id", value: record["id"]}],
606
+ increment: {failedVerificationCount: 1},
607
+ allow_server_managed: true
608
+ )
609
+ raise Error, "Failed to record two-factor verification failure" unless updated
610
+ return if updated["failedVerificationCount"].to_i < lockout[:max_failed_attempts].to_i
611
+
612
+ locked = ctx.context.adapter.update(
613
+ model: TWO_FACTOR_MODEL,
614
+ where: [{field: "id", value: record["id"]}],
615
+ update: {lockedUntil: Time.now + lockout[:duration_seconds].to_i}
616
+ )
617
+ raise Error, "Failed to lock two-factor account" unless locked
618
+ end
619
+
620
+ def two_factor_reset_failures!(ctx, config, record)
621
+ lockout = config[:account_lockout]
622
+ return unless lockout && lockout[:enabled] != false
623
+
624
+ reset = ctx.context.adapter.update(
625
+ model: TWO_FACTOR_MODEL,
626
+ where: [{field: "id", value: record["id"]}],
627
+ update: {failedVerificationCount: 0, lockedUntil: nil}
628
+ )
629
+ raise Error, "Failed to reset two-factor verification failures" unless reset
630
+ end
631
+
632
+ def two_factor_lock_time(value)
633
+ value.is_a?(Time) ? value : Time.parse(value.to_s)
634
+ end
635
+
448
636
  def two_factor_methods(ctx, config, user_id)
449
637
  methods = []
450
638
  unless config[:totp_options][:disable]
@@ -536,8 +724,6 @@ module BetterAuth
536
724
  stored
537
725
  end
538
726
  JSON.parse(data.to_s)
539
- rescue JSON::ParserError
540
- []
541
727
  end
542
728
 
543
729
  def two_factor_random_digits(length)
@@ -73,10 +73,12 @@ module BetterAuth
73
73
  "Signed in",
74
74
  OpenAPI.object_schema(
75
75
  {
76
+ redirect: {type: "boolean"},
76
77
  token: {type: "string"},
78
+ url: {type: ["string", "null"]},
77
79
  user: {type: "object", "$ref": "#/components/schemas/User"}
78
80
  },
79
- required: ["token", "user"]
81
+ required: ["redirect", "token", "user"]
80
82
  )
81
83
  )
82
84
  }
@@ -88,17 +90,18 @@ module BetterAuth
88
90
  password = body[:password].to_s
89
91
  callback_url = body[:callback_url] || body[:callbackURL]
90
92
  remember_me = body.key?(:remember_me) ? body[:remember_me] : body[:rememberMe]
93
+ Routes.preflight_sign_in_token_link!(ctx)
91
94
 
92
95
  if raw_username.empty? || password.empty?
93
96
  raise APIError.new("UNAUTHORIZED", message: USERNAME_ERROR_CODES["INVALID_USERNAME_OR_PASSWORD"])
94
97
  end
95
98
 
96
- username = username_for_validation(raw_username, config)
99
+ username = username_for_sign_in_validation(raw_username, config)
97
100
  validate_username!(username, config, status: "UNPROCESSABLE_ENTITY")
98
101
 
99
102
  user = ctx.context.adapter.find_one(
100
103
  model: "user",
101
- where: [{field: "username", value: normalize_username(username, config)}]
104
+ where: [{field: "username", value: normalize_username(raw_username, config)}]
102
105
  )
103
106
  unless user
104
107
  Routes.hash_password(ctx, password)
@@ -134,8 +137,11 @@ module BetterAuth
134
137
  raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
135
138
 
136
139
  Cookies.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
140
+ ctx.set_header("location", callback_url) if callback_url
137
141
  ctx.json({
142
+ redirect: !callback_url.nil?,
138
143
  token: session["token"],
144
+ url: callback_url,
139
145
  user: Schema.parse_output(ctx.context.options, "user", user)
140
146
  })
141
147
  end
@@ -207,12 +213,36 @@ module BetterAuth
207
213
  end
208
214
 
209
215
  def username_database_hooks(config)
210
- before_hook = lambda do |user, _context|
216
+ before_hook = lambda do |user, endpoint_context|
211
217
  data = user.dup
212
- if data["username"].is_a?(String) && !data["username"].empty?
213
- data["username"] = normalize_username(data["username"], config)
218
+ raw_username = data["username"]
219
+ raw_display_username = data["displayUsername"]
220
+ if raw_username.is_a?(String) && !raw_username.empty?
221
+ unless endpoint_context && username_mutation_path?(endpoint_context.path)
222
+ username = username_for_validation(raw_username, config)
223
+ validate_username!(username, config, status: "BAD_REQUEST")
224
+ adapter = endpoint_context&.context&.adapter
225
+ if adapter
226
+ existing = adapter.find_one(model: "user", where: [{field: "username", value: normalize_username(username, config)}])
227
+ current_user_id = endpoint_context&.context&.current_session&.dig(:user, "id")
228
+ if existing && existing["id"] != current_user_id
229
+ raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
230
+ end
231
+ end
232
+ end
233
+ data["username"] = normalize_username(raw_username, config)
234
+ data["displayUsername"] = raw_username unless raw_display_username.is_a?(String) && !raw_display_username.empty?
214
235
  end
215
236
  if data["displayUsername"].is_a?(String) && !data["displayUsername"].empty?
237
+ display_username = if validation_order(config, :display_username) == "post-normalization"
238
+ normalize_display_username(data["displayUsername"], config)
239
+ else
240
+ data["displayUsername"]
241
+ end
242
+ validator = config[:display_username_validator]
243
+ unless !validator.respond_to?(:call) || validator.call(display_username)
244
+ raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
245
+ end
216
246
  data["displayUsername"] = normalize_display_username(data["displayUsername"], config)
217
247
  end
218
248
  {data: data}
@@ -229,6 +259,15 @@ module BetterAuth
229
259
  def validate_username_mutation!(ctx, config)
230
260
  body = normalize_hash(ctx.body)
231
261
  raw_username = body.key?(:username) ? body[:username] : nil
262
+ if raw_username.nil? && present?(body[:display_username])
263
+ begin
264
+ validate_username!(username_for_validation(body[:display_username], config), config, status: "BAD_REQUEST")
265
+ body[:username] = body[:display_username]
266
+ raw_username = body[:username]
267
+ rescue APIError
268
+ # A display-only value that cannot be a username remains display-only.
269
+ end
270
+ end
232
271
  username = if raw_username.is_a?(String) && validation_order(config, :username) == "post-normalization"
233
272
  normalize_username(raw_username, config)
234
273
  else
@@ -242,11 +281,11 @@ module BetterAuth
242
281
  same_user = existing && current && existing["id"] == current[:session]["userId"]
243
282
 
244
283
  if existing && ctx.path == "/sign-up/email"
245
- raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
284
+ raise APIError.new("UNPROCESSABLE_ENTITY", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
246
285
  end
247
286
 
248
287
  if existing && ctx.path == "/update-user" && !same_user
249
- raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
288
+ raise APIError.new("BAD_REQUEST", code: "USERNAME_IS_ALREADY_TAKEN", message: USERNAME_ERROR_CODES["USERNAME_IS_ALREADY_TAKEN"])
250
289
  end
251
290
  end
252
291
 
@@ -260,35 +299,39 @@ module BetterAuth
260
299
  if display_username.is_a?(String)
261
300
  validator = config[:display_username_validator]
262
301
  unless !validator.respond_to?(:call) || validator.call(display_username)
263
- raise APIError.new("BAD_REQUEST", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
302
+ raise APIError.new("BAD_REQUEST", code: "INVALID_DISPLAY_USERNAME", message: USERNAME_ERROR_CODES["INVALID_DISPLAY_USERNAME"])
264
303
  end
265
304
  end
305
+ ctx.body = body
266
306
  nil
267
307
  end
268
308
 
269
309
  def mirror_username_fields!(ctx)
270
310
  body = normalize_hash(ctx.body)
271
311
  body[:display_username] = body[:username] if present?(body[:username]) && !present?(body[:display_username])
272
- body[:username] = body[:display_username] if present?(body[:display_username]) && !present?(body[:username])
273
312
  ctx.body = body
274
313
  nil
275
314
  end
276
315
 
277
316
  def validate_username!(username, config, status:)
278
317
  if username.length < min_username_length(config)
279
- raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
318
+ raise APIError.new(status, code: "USERNAME_TOO_SHORT", message: USERNAME_ERROR_CODES["USERNAME_TOO_SHORT"])
280
319
  end
281
320
 
282
321
  if username.length > max_username_length(config)
283
- raise APIError.new(status, message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"])
322
+ raise APIError.new(status, code: "USERNAME_TOO_LONG", message: USERNAME_ERROR_CODES["USERNAME_TOO_LONG"])
284
323
  end
285
324
 
286
325
  validator = config[:username_validator]
287
326
  valid = validator.respond_to?(:call) ? validator.call(username) : default_username_valid?(username)
288
- raise APIError.new(status, message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
327
+ raise APIError.new(status, code: "INVALID_USERNAME", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) unless valid
289
328
  end
290
329
 
291
330
  def username_for_validation(username, config)
331
+ (validation_order(config, :username) == "post-normalization") ? normalize_username(username, config) : username
332
+ end
333
+
334
+ def username_for_sign_in_validation(username, config)
292
335
  (validation_order(config, :username) == "pre-normalization") ? normalize_username(username, config) : username
293
336
  end
294
337
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "plugin_loader"
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
@@ -39,8 +41,63 @@ module BetterAuth
39
41
  data[key] || data[key.to_s] || data[Schema.storage_key(key)] || data[Schema.storage_key(key).to_sym] || data[normalize_key(key)]
40
42
  end
41
43
 
44
+ def deep_merge_hashes(base, override)
45
+ base.merge(override) do |_key, old_value, new_value|
46
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
47
+ deep_merge_hashes(old_value, new_value)
48
+ else
49
+ new_value
50
+ end
51
+ end
52
+ end
53
+
42
54
  def cookie_header_from_set_cookie(set_cookie)
43
55
  set_cookie.to_s.lines.map { |line| line.split(";").first }.join("; ")
44
56
  end
57
+
58
+ PLUGIN_FACTORY_LOADERS = {
59
+ create_access_control: :access,
60
+ createAccessControl: :access
61
+ }.freeze
62
+
63
+ REMOVED_PLUGIN_FACTORIES = {
64
+ oidc_provider: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")",
65
+ mcp: "BetterAuth::Plugins.oauth_provider (require \"better_auth/oauth_provider\")"
66
+ }.freeze
67
+
68
+ def method_missing(name, ...)
69
+ if (replacement = REMOVED_PLUGIN_FACTORIES[name.to_sym])
70
+ raise ArgumentError, "BetterAuth::Plugins.#{name} was removed. Use #{replacement} instead."
71
+ end
72
+
73
+ if (loader = plugin_loader_for_method(name))
74
+ load_plugin!(loader)
75
+ return public_send(name, ...) if respond_to?(name, true)
76
+
77
+ raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
78
+ end
79
+
80
+ if (loader = PLUGIN_FACTORY_LOADERS[name.to_sym])
81
+ load_plugin!(loader)
82
+ return public_send(name, ...) if respond_to?(name, true)
83
+
84
+ raise NoMethodError, "plugin file for #{loader} did not define BetterAuth::Plugins.#{name}"
85
+ end
86
+
87
+ super
88
+ end
89
+
90
+ def respond_to_missing?(name, include_private = false)
91
+ return false if REMOVED_PLUGIN_FACTORIES.key?(name.to_sym)
92
+
93
+ PLUGIN_FACTORY_LOADERS.key?(name.to_sym) || !plugin_loader_for_method(name).nil? || super
94
+ end
95
+
96
+ def const_missing(name)
97
+ load_plugin_for_constant!(name)
98
+ return const_get(name) if const_defined?(name, false)
99
+
100
+ super
101
+ end
45
102
  end
46
103
  end