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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/LICENSE.md +1 -1
- data/README.md +142 -95
- 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 +303 -54
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +390 -63
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +48 -47
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +119 -15
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +108 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/http_client.rb +46 -0
- 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 +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- 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 +85 -29
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- 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 +77 -5
- data/lib/better_auth/plugins/dub.rb +10 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +162 -140
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +44 -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 +233 -46
- 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 +78 -65
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +77 -9
- data/lib/better_auth/plugins/organization.rb +546 -102
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +25 -27
- 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 +205 -19
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +300 -133
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +44 -8
- data/lib/better_auth/routes/email_verification.rb +93 -6
- data/lib/better_auth/routes/password.rb +6 -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 +197 -54
- data/lib/better_auth/routes/user.rb +17 -10
- data/lib/better_auth/schema/sql.rb +189 -34
- data/lib/better_auth/schema.rb +159 -5
- data/lib/better_auth/session.rb +29 -10
- 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 +45 -23
- 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/github.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 +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +14 -41
- metadata +14 -18
- 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 -215
- 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
|
-
|
|
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
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
434
|
-
|
|
435
|
-
|
|
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 =
|
|
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
|