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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/LICENSE.md +1 -1
- data/README.md +138 -91
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +191 -56
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +294 -49
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +41 -46
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +110 -14
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +14 -3
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +4 -4
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +79 -28
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +47 -9
- data/lib/better_auth/plugins/dub.rb +2 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +141 -146
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +37 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +61 -60
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +35 -7
- data/lib/better_auth/plugins/organization.rb +455 -122
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +24 -26
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +186 -21
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +297 -135
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +40 -8
- data/lib/better_auth/routes/email_verification.rb +88 -5
- data/lib/better_auth/routes/password.rb +5 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +169 -54
- data/lib/better_auth/routes/user.rb +11 -8
- data/lib/better_auth/schema/sql.rb +90 -24
- data/lib/better_auth/schema.rb +124 -3
- data/lib/better_auth/session.rb +27 -9
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +41 -14
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +5 -5
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +11 -41
- metadata +5 -13
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -256
- data/lib/better_auth/plugins/oidc_provider.rb +0 -769
|
@@ -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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
@@ -317,9 +373,9 @@ module BetterAuth
|
|
|
317
373
|
when "generateTOTP"
|
|
318
374
|
OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"])
|
|
319
375
|
when "verifyTOTP", "verifyTwoFactorOTP"
|
|
320
|
-
OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}
|
|
376
|
+
OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}}, required: ["code"])
|
|
321
377
|
when "verifyBackupCode"
|
|
322
|
-
OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"},
|
|
378
|
+
OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, trustDevice: {type: "boolean"}}, required: ["code"])
|
|
323
379
|
when "sendTwoFactorOTP"
|
|
324
380
|
OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
|
|
325
381
|
else
|
|
@@ -371,7 +427,9 @@ module BetterAuth
|
|
|
371
427
|
secret: {type: "string", required: true, returned: false, index: true},
|
|
372
428
|
backupCodes: {type: "string", required: true, returned: false},
|
|
373
429
|
userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
|
|
374
|
-
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}
|
|
375
433
|
}
|
|
376
434
|
}
|
|
377
435
|
}
|
|
@@ -391,10 +449,16 @@ module BetterAuth
|
|
|
391
449
|
ctx.context.internal_adapter.delete_session(data[:session]["token"])
|
|
392
450
|
cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME, max_age: config[:two_factor_cookie_max_age])
|
|
393
451
|
identifier = "2fa-#{Crypto.random_string(20)}"
|
|
452
|
+
expires_at = Time.now + config[:two_factor_cookie_max_age].to_i
|
|
394
453
|
ctx.context.internal_adapter.create_verification_value(
|
|
395
454
|
identifier: identifier,
|
|
396
455
|
value: data[:user]["id"],
|
|
397
|
-
expiresAt:
|
|
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
|
|
398
462
|
)
|
|
399
463
|
ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
|
|
400
464
|
ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
|
|
@@ -404,7 +468,13 @@ module BetterAuth
|
|
|
404
468
|
session = Routes.current_session(ctx, allow_nil: true)
|
|
405
469
|
if session
|
|
406
470
|
key = "#{session[:user]["id"]}!#{session[:session]["id"]}"
|
|
407
|
-
|
|
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
|
+
}
|
|
408
478
|
end
|
|
409
479
|
|
|
410
480
|
cookie = ctx.context.create_auth_cookie(TWO_FACTOR_COOKIE_NAME)
|
|
@@ -418,11 +488,15 @@ module BetterAuth
|
|
|
418
488
|
raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_TWO_FACTOR_COOKIE"]) unless user
|
|
419
489
|
|
|
420
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
|
+
|
|
421
496
|
dont_remember_me = Cookies.dont_remember?(ctx)
|
|
422
497
|
new_session = ctx.context.internal_adapter.create_session(user["id"], dont_remember_me)
|
|
423
498
|
raise APIError.new("INTERNAL_SERVER_ERROR", message: "failed to create session") unless new_session
|
|
424
499
|
|
|
425
|
-
ctx.context.internal_adapter.delete_verification_value(verification["id"])
|
|
426
500
|
Cookies.set_session_cookie(ctx, {session: new_session, user: user}, dont_remember_me)
|
|
427
501
|
Cookies.expire_cookie(ctx, cookie)
|
|
428
502
|
if normalize_hash(ctx.body)[:trust_device]
|
|
@@ -432,7 +506,41 @@ module BetterAuth
|
|
|
432
506
|
ctx.json({token: new_session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
|
|
433
507
|
end
|
|
434
508
|
|
|
435
|
-
|
|
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}
|
|
436
544
|
end
|
|
437
545
|
|
|
438
546
|
def two_factor_set_trusted_device(ctx, config, user_id)
|
|
@@ -451,9 +559,10 @@ module BetterAuth
|
|
|
451
559
|
|
|
452
560
|
token, identifier = value.split("!", 2)
|
|
453
561
|
expected = Crypto.hmac_signature("#{user_id}!#{identifier}", ctx.context.secret, encoding: :base64url)
|
|
454
|
-
verification = identifier &&
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
457
566
|
two_factor_set_trusted_device(ctx, config, user_id)
|
|
458
567
|
true
|
|
459
568
|
else
|
|
@@ -466,6 +575,64 @@ module BetterAuth
|
|
|
466
575
|
ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
|
|
467
576
|
end
|
|
468
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
|
+
|
|
469
636
|
def two_factor_methods(ctx, config, user_id)
|
|
470
637
|
methods = []
|
|
471
638
|
unless config[:totp_options][:disable]
|
|
@@ -557,8 +724,6 @@ module BetterAuth
|
|
|
557
724
|
stored
|
|
558
725
|
end
|
|
559
726
|
JSON.parse(data.to_s)
|
|
560
|
-
rescue JSON::ParserError
|
|
561
|
-
[]
|
|
562
727
|
end
|
|
563
728
|
|
|
564
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 =
|
|
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(
|
|
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,
|
|
216
|
+
before_hook = lambda do |user, endpoint_context|
|
|
211
217
|
data = user.dup
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
data/lib/better_auth/plugins.rb
CHANGED
|
@@ -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
|