better_auth 0.3.0 → 0.4.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 +15 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -2
- data/lib/better_auth/adapters/memory.rb +57 -11
- data/lib/better_auth/adapters/sql.rb +27 -2
- data/lib/better_auth/api.rb +6 -1
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/database_hooks.rb +3 -3
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +5 -2
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugins/email_otp.rb +16 -5
- data/lib/better_auth/plugins/generic_oauth.rb +14 -28
- data/lib/better_auth/plugins/oauth_protocol.rb +171 -28
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +51 -20
- data/lib/better_auth/plugins/two_factor.rb +53 -18
- data/lib/better_auth/rate_limiter.rb +18 -0
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/routes/account.rb +16 -4
- data/lib/better_auth/routes/password.rb +2 -1
- data/lib/better_auth/routes/sign_in.rb +2 -0
- data/lib/better_auth/routes/sign_up.rb +8 -0
- data/lib/better_auth/routes/social.rb +30 -0
- data/lib/better_auth/routes/user.rb +9 -3
- data/lib/better_auth/session.rb +12 -1
- data/lib/better_auth/url_helpers.rb +195 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +8 -0
- metadata +9 -1
|
@@ -272,6 +272,12 @@ module BetterAuth
|
|
|
272
272
|
Endpoint.new(path: "/organization/set-active", method: "POST") do |ctx|
|
|
273
273
|
session = Routes.current_session(ctx, sensitive: true)
|
|
274
274
|
body = normalize_hash(ctx.body)
|
|
275
|
+
if body.key?(:organization_id) && body[:organization_id].nil?
|
|
276
|
+
updated_session = ctx.context.internal_adapter.update_session(session[:session]["token"], {activeOrganizationId: nil, activeTeamId: nil})
|
|
277
|
+
Cookies.set_session_cookie(ctx, {session: updated_session || session[:session].merge("activeOrganizationId" => nil, "activeTeamId" => nil), user: session[:user]})
|
|
278
|
+
next ctx.json(nil)
|
|
279
|
+
end
|
|
280
|
+
|
|
275
281
|
organization = organization_by_id(ctx, body[:organization_id]) || organization_by_slug(ctx, body[:organization_slug])
|
|
276
282
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
|
|
277
283
|
require_member!(ctx, session[:user]["id"], organization["id"])
|
|
@@ -285,11 +291,16 @@ module BetterAuth
|
|
|
285
291
|
Endpoint.new(path: "/organization/get-full-organization", method: "GET") do |ctx|
|
|
286
292
|
session = Routes.current_session(ctx)
|
|
287
293
|
query = normalize_hash(ctx.query)
|
|
294
|
+
explicit_lookup = query.key?(:organization_slug) || query.key?(:organization_id)
|
|
288
295
|
organization = organization_by_slug(ctx, query[:organization_slug]) || organization_by_id(ctx, query[:organization_id] || session[:session]["activeOrganizationId"])
|
|
289
|
-
|
|
296
|
+
unless organization
|
|
297
|
+
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) if explicit_lookup
|
|
298
|
+
|
|
299
|
+
next ctx.json(nil)
|
|
300
|
+
end
|
|
290
301
|
|
|
291
302
|
require_member!(ctx, session[:user]["id"], organization["id"])
|
|
292
|
-
members = list_members_for(ctx, organization["id"])
|
|
303
|
+
members = list_members_for(ctx, organization["id"], {limit: query[:members_limit] || config[:membership_limit]})
|
|
293
304
|
invitations = ctx.context.adapter.find_many(model: "invitation", where: [{field: "organizationId", value: organization["id"]}])
|
|
294
305
|
result = organization_wire(ctx, organization).merge(
|
|
295
306
|
members: members.fetch(:members),
|
|
@@ -307,7 +318,7 @@ module BetterAuth
|
|
|
307
318
|
Endpoint.new(path: "/organization/invite-member", method: "POST") do |ctx|
|
|
308
319
|
session = Routes.current_session(ctx)
|
|
309
320
|
body = normalize_hash(ctx.body)
|
|
310
|
-
organization = organization_by_id(ctx, body[:organization_id])
|
|
321
|
+
organization = organization_by_id(ctx, body[:organization_id] || session[:session]["activeOrganizationId"]) || organization_by_slug(ctx, body[:organization_slug])
|
|
311
322
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("ORGANIZATION_NOT_FOUND")) unless organization
|
|
312
323
|
require_org_permission!(ctx, config, session, organization["id"], {invitation: ["create"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"))
|
|
313
324
|
email = body[:email].to_s.downcase
|
|
@@ -334,21 +345,26 @@ module BetterAuth
|
|
|
334
345
|
raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("INVITATION_LIMIT_REACHED"))
|
|
335
346
|
end
|
|
336
347
|
team_ids = organization_team_ids(body[:team_id] || body[:team_ids])
|
|
348
|
+
ensure_team_member_capacity!(ctx, config, team_ids)
|
|
349
|
+
invitation_data = {
|
|
350
|
+
organizationId: organization["id"],
|
|
351
|
+
email: email,
|
|
352
|
+
role: role,
|
|
353
|
+
status: "pending",
|
|
354
|
+
expiresAt: Time.now + config[:invitation_expires_in].to_i,
|
|
355
|
+
inviterId: session[:user]["id"],
|
|
356
|
+
teamId: team_ids.any? ? team_ids.join(",") : nil,
|
|
357
|
+
createdAt: Time.now
|
|
358
|
+
}.merge(additional_input(body, :organization_id, :organization_slug, :email, :role, :team_id, :team_ids))
|
|
359
|
+
merge_hook_data!(invitation_data, run_org_hook(config, :before_create_invitation, {invitation: invitation_data, inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx))
|
|
337
360
|
invitation = ctx.context.adapter.create(
|
|
338
361
|
model: "invitation",
|
|
339
|
-
data:
|
|
340
|
-
|
|
341
|
-
email: email,
|
|
342
|
-
role: role,
|
|
343
|
-
status: "pending",
|
|
344
|
-
expiresAt: Time.now + config[:invitation_expires_in].to_i,
|
|
345
|
-
inviterId: session[:user]["id"],
|
|
346
|
-
teamId: team_ids.any? ? team_ids.join(",") : nil,
|
|
347
|
-
createdAt: Time.now
|
|
348
|
-
}
|
|
362
|
+
data: invitation_data,
|
|
363
|
+
force_allow_id: true
|
|
349
364
|
)
|
|
350
365
|
sender = config[:send_invitation_email]
|
|
351
366
|
sender.call({id: invitation["id"], role: role, email: email, organization: organization_wire(ctx, organization), invitation: invitation_wire(ctx, invitation), inviter: require_member!(ctx, session[:user]["id"], organization["id"])}, ctx.request) if sender.respond_to?(:call)
|
|
367
|
+
run_org_hook(config, :after_create_invitation, {invitation: invitation_wire(ctx, invitation), inviter: session[:user], organization: organization_wire(ctx, organization)}, ctx)
|
|
352
368
|
ctx.json(invitation_wire(ctx, invitation))
|
|
353
369
|
end
|
|
354
370
|
end
|
|
@@ -365,6 +381,7 @@ module BetterAuth
|
|
|
365
381
|
if config[:require_email_verification_on_invitation] && !session[:user]["emailVerified"]
|
|
366
382
|
raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION"))
|
|
367
383
|
end
|
|
384
|
+
ensure_team_member_capacity!(ctx, config, organization_team_ids(invitation["teamId"]))
|
|
368
385
|
member = ctx.context.adapter.create(model: "member", data: {organizationId: invitation["organizationId"], userId: session[:user]["id"], role: invitation["role"], createdAt: Time.now})
|
|
369
386
|
organization_team_ids(invitation["teamId"]).each do |team_id|
|
|
370
387
|
ctx.context.adapter.create(model: "teamMember", data: {teamId: team_id, userId: session[:user]["id"], createdAt: Time.now})
|
|
@@ -488,10 +505,12 @@ module BetterAuth
|
|
|
488
505
|
def organization_get_active_member_role_endpoint(_config)
|
|
489
506
|
Endpoint.new(path: "/organization/get-active-member-role", method: "GET") do |ctx|
|
|
490
507
|
session = Routes.current_session(ctx)
|
|
491
|
-
|
|
508
|
+
query = normalize_hash(ctx.query)
|
|
509
|
+
organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
|
|
492
510
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
|
|
493
|
-
|
|
494
|
-
ctx
|
|
511
|
+
require_member!(ctx, session[:user]["id"], organization_id)
|
|
512
|
+
member = require_member!(ctx, query[:user_id] || session[:user]["id"], organization_id)
|
|
513
|
+
ctx.json({role: member["role"], member: member_wire(ctx, member)})
|
|
495
514
|
end
|
|
496
515
|
end
|
|
497
516
|
|
|
@@ -511,7 +530,7 @@ module BetterAuth
|
|
|
511
530
|
Endpoint.new(path: "/organization/list-members", method: "GET") do |ctx|
|
|
512
531
|
session = Routes.current_session(ctx)
|
|
513
532
|
query = normalize_hash(ctx.query)
|
|
514
|
-
organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id")
|
|
533
|
+
organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
|
|
515
534
|
raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
|
|
516
535
|
require_member!(ctx, session[:user]["id"], organization_id)
|
|
517
536
|
ctx.json(list_members_for(ctx, organization_id, query))
|
|
@@ -543,7 +562,7 @@ module BetterAuth
|
|
|
543
562
|
end
|
|
544
563
|
team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name))
|
|
545
564
|
merge_hook_data!(team_data, run_org_hook(config, :before_create_team, {team: team_data, user: session[:user], organization: organization_wire(ctx, organization)}, ctx))
|
|
546
|
-
team = ctx.context.adapter.create(model: "team", data: team_data)
|
|
565
|
+
team = ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
|
|
547
566
|
ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
|
|
548
567
|
run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
|
|
549
568
|
ctx.json(team_wire(ctx, team))
|
|
@@ -864,7 +883,7 @@ module BetterAuth
|
|
|
864
883
|
where: where,
|
|
865
884
|
limit: query[:limit],
|
|
866
885
|
offset: query[:offset],
|
|
867
|
-
sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_order] || "asc"} : nil
|
|
886
|
+
sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
|
|
868
887
|
)
|
|
869
888
|
{
|
|
870
889
|
members: members.map { |entry| member_wire(ctx, entry) },
|
|
@@ -872,6 +891,18 @@ module BetterAuth
|
|
|
872
891
|
}
|
|
873
892
|
end
|
|
874
893
|
|
|
894
|
+
def ensure_team_member_capacity!(ctx, config, team_ids)
|
|
895
|
+
max_members = config.dig(:teams, :maximum_members_per_team)
|
|
896
|
+
return unless max_members && team_ids.any?
|
|
897
|
+
|
|
898
|
+
team_ids.each do |team_id|
|
|
899
|
+
count = ctx.context.adapter.count(model: "teamMember", where: [{field: "teamId", value: team_id}])
|
|
900
|
+
if count >= max_members.to_i
|
|
901
|
+
raise APIError.new("FORBIDDEN", message: ORGANIZATION_ERROR_CODES.fetch("TEAM_MEMBER_LIMIT_REACHED"))
|
|
902
|
+
end
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
|
|
875
906
|
def member_wire(ctx, member)
|
|
876
907
|
data = Schema.parse_output(ctx.context.options, "member", member)
|
|
877
908
|
user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
|
|
@@ -915,7 +946,7 @@ module BetterAuth
|
|
|
915
946
|
team = if custom.respond_to?(:call)
|
|
916
947
|
custom.call(organization_wire(ctx, organization), ctx)
|
|
917
948
|
else
|
|
918
|
-
ctx.context.adapter.create(model: "team", data: team_data)
|
|
949
|
+
ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
|
|
919
950
|
end
|
|
920
951
|
ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
|
|
921
952
|
run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
|
|
@@ -24,6 +24,7 @@ module BetterAuth
|
|
|
24
24
|
TRUST_DEVICE_COOKIE_NAME = "trust_device"
|
|
25
25
|
TRUST_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
|
|
26
26
|
TWO_FACTOR_COOKIE_MAX_AGE = 10 * 60
|
|
27
|
+
TWO_FACTOR_MODEL = "twoFactor"
|
|
27
28
|
|
|
28
29
|
module_function
|
|
29
30
|
|
|
@@ -39,6 +40,8 @@ module BetterAuth
|
|
|
39
40
|
config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
|
|
40
41
|
config[:otp_options] = normalize_hash(config[:otp_options])
|
|
41
42
|
config[:totp_options] = normalize_hash(config[:totp_options])
|
|
43
|
+
config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless)
|
|
44
|
+
config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless)
|
|
42
45
|
|
|
43
46
|
Plugin.new(
|
|
44
47
|
id: "two-factor",
|
|
@@ -62,7 +65,7 @@ module BetterAuth
|
|
|
62
65
|
}
|
|
63
66
|
]
|
|
64
67
|
},
|
|
65
|
-
schema: two_factor_schema(config
|
|
68
|
+
schema: two_factor_schema(config),
|
|
66
69
|
rate_limit: [
|
|
67
70
|
{
|
|
68
71
|
path_matcher: ->(path) { path.start_with?("/two-factor/") },
|
|
@@ -79,7 +82,7 @@ module BetterAuth
|
|
|
79
82
|
Endpoint.new(path: "/two-factor/enable", method: "POST") do |ctx|
|
|
80
83
|
session = Routes.current_session(ctx, sensitive: true)
|
|
81
84
|
body = normalize_hash(ctx.body)
|
|
82
|
-
two_factor_check_password!(ctx, session[:user]["id"], body[:password])
|
|
85
|
+
two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
|
|
83
86
|
|
|
84
87
|
secret = two_factor_generate_secret
|
|
85
88
|
backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options])
|
|
@@ -90,14 +93,18 @@ module BetterAuth
|
|
|
90
93
|
ctx.context.internal_adapter.delete_session(session[:session]["token"])
|
|
91
94
|
end
|
|
92
95
|
|
|
93
|
-
ctx
|
|
96
|
+
existing = two_factor_record(ctx, config, session[:user]["id"])
|
|
97
|
+
verified = (!!existing && existing["verified"] != false) || !!config[:skip_verification_on_enable]
|
|
98
|
+
ctx.context.adapter.delete_many(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: session[:user]["id"]}])
|
|
94
99
|
ctx.context.adapter.create(
|
|
95
|
-
model:
|
|
100
|
+
model: TWO_FACTOR_MODEL,
|
|
96
101
|
data: {
|
|
97
102
|
secret: Crypto.symmetric_encrypt(key: ctx.context.secret, data: secret),
|
|
98
103
|
backupCodes: backup[:stored],
|
|
99
|
-
userId: session[:user]["id"]
|
|
100
|
-
|
|
104
|
+
userId: session[:user]["id"],
|
|
105
|
+
verified: verified
|
|
106
|
+
},
|
|
107
|
+
force_allow_id: true
|
|
101
108
|
)
|
|
102
109
|
|
|
103
110
|
ctx.json({
|
|
@@ -111,10 +118,10 @@ module BetterAuth
|
|
|
111
118
|
Endpoint.new(path: "/two-factor/disable", method: "POST") do |ctx|
|
|
112
119
|
session = Routes.current_session(ctx, sensitive: true)
|
|
113
120
|
body = normalize_hash(ctx.body)
|
|
114
|
-
two_factor_check_password!(ctx, session[:user]["id"], body[:password])
|
|
121
|
+
two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
|
|
115
122
|
|
|
116
123
|
updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false)
|
|
117
|
-
ctx.context.adapter.delete(model:
|
|
124
|
+
ctx.context.adapter.delete(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: updated_user["id"]}])
|
|
118
125
|
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
119
126
|
Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
|
|
120
127
|
ctx.context.internal_adapter.delete_session(session[:session]["token"])
|
|
@@ -142,7 +149,7 @@ module BetterAuth
|
|
|
142
149
|
Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx|
|
|
143
150
|
two_factor_totp_enabled!(config)
|
|
144
151
|
session = Routes.current_session(ctx, sensitive: true)
|
|
145
|
-
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
|
|
152
|
+
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:totp_options][:allow_passwordless])
|
|
146
153
|
record = two_factor_record(ctx, config, session[:user]["id"])
|
|
147
154
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
|
|
148
155
|
|
|
@@ -158,11 +165,22 @@ module BetterAuth
|
|
|
158
165
|
data = two_factor_verification_context(ctx, config)
|
|
159
166
|
record = two_factor_record(ctx, config, data[:session][:user]["id"])
|
|
160
167
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
|
|
168
|
+
if !data[:session][:session] && record["verified"] == false
|
|
169
|
+
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"])
|
|
170
|
+
end
|
|
161
171
|
|
|
162
172
|
secret = Crypto.symmetric_decrypt(key: ctx.context.secret, data: record["secret"])
|
|
163
173
|
raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
|
|
164
174
|
|
|
165
|
-
if
|
|
175
|
+
if record["verified"] != true
|
|
176
|
+
if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
|
|
177
|
+
updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
|
|
178
|
+
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
179
|
+
ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
|
|
180
|
+
Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
|
|
181
|
+
end
|
|
182
|
+
ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {verified: true})
|
|
183
|
+
elsif !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
|
|
166
184
|
updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
|
|
167
185
|
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
168
186
|
ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
|
|
@@ -242,7 +260,7 @@ module BetterAuth
|
|
|
242
260
|
remaining = codes.reject { |code| code == body[:code].to_s }
|
|
243
261
|
stored = two_factor_store_backup_codes(ctx.context.secret, remaining, config[:backup_code_options])
|
|
244
262
|
updated = ctx.context.adapter.update(
|
|
245
|
-
model:
|
|
263
|
+
model: TWO_FACTOR_MODEL,
|
|
246
264
|
where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
|
|
247
265
|
update: {backupCodes: stored}
|
|
248
266
|
)
|
|
@@ -257,12 +275,12 @@ module BetterAuth
|
|
|
257
275
|
session = Routes.current_session(ctx, sensitive: true)
|
|
258
276
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"]
|
|
259
277
|
|
|
260
|
-
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
|
|
278
|
+
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:backup_code_options][:allow_passwordless])
|
|
261
279
|
record = two_factor_record(ctx, config, session[:user]["id"])
|
|
262
280
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record
|
|
263
281
|
|
|
264
282
|
backup = two_factor_generate_backup_codes(ctx.context.secret, config[:backup_code_options])
|
|
265
|
-
ctx.context.adapter.update(model:
|
|
283
|
+
ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
|
|
266
284
|
ctx.json({status: true, backupCodes: backup[:codes]})
|
|
267
285
|
end
|
|
268
286
|
end
|
|
@@ -277,7 +295,8 @@ module BetterAuth
|
|
|
277
295
|
end
|
|
278
296
|
end
|
|
279
297
|
|
|
280
|
-
def two_factor_schema(
|
|
298
|
+
def two_factor_schema(config = {})
|
|
299
|
+
custom_schema = config[:schema]
|
|
281
300
|
base = {
|
|
282
301
|
user: {
|
|
283
302
|
fields: {
|
|
@@ -288,10 +307,14 @@ module BetterAuth
|
|
|
288
307
|
fields: {
|
|
289
308
|
secret: {type: "string", required: true, returned: false, index: true},
|
|
290
309
|
backupCodes: {type: "string", required: true, returned: false},
|
|
291
|
-
userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}}
|
|
310
|
+
userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
|
|
311
|
+
verified: {type: "boolean", required: false, default_value: true, input: false}
|
|
292
312
|
}
|
|
293
313
|
}
|
|
294
314
|
}
|
|
315
|
+
if config[:two_factor_table] && config[:two_factor_table] != TWO_FACTOR_MODEL
|
|
316
|
+
base[:twoFactor][:model_name] = config[:two_factor_table].to_s
|
|
317
|
+
end
|
|
295
318
|
deep_merge_hashes(base, normalize_hash(custom_schema || {}))
|
|
296
319
|
end
|
|
297
320
|
|
|
@@ -311,7 +334,7 @@ module BetterAuth
|
|
|
311
334
|
expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
|
|
312
335
|
)
|
|
313
336
|
ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
|
|
314
|
-
ctx.json({twoFactorRedirect: true})
|
|
337
|
+
ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
|
|
315
338
|
end
|
|
316
339
|
|
|
317
340
|
def two_factor_verification_context(ctx, config)
|
|
@@ -377,11 +400,23 @@ module BetterAuth
|
|
|
377
400
|
end
|
|
378
401
|
|
|
379
402
|
def two_factor_record(ctx, config, user_id)
|
|
380
|
-
ctx.context.adapter.find_one(model:
|
|
403
|
+
ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
|
|
381
404
|
end
|
|
382
405
|
|
|
383
|
-
def
|
|
406
|
+
def two_factor_methods(ctx, config, user_id)
|
|
407
|
+
methods = []
|
|
408
|
+
unless config[:totp_options][:disable]
|
|
409
|
+
record = two_factor_record(ctx, config, user_id)
|
|
410
|
+
methods << "totp" if record && record["verified"] != false
|
|
411
|
+
end
|
|
412
|
+
methods << "otp" if config[:otp_options][:send_otp].respond_to?(:call)
|
|
413
|
+
methods
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def two_factor_check_password!(ctx, user_id, password, allow_passwordless: false)
|
|
384
417
|
account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
|
|
418
|
+
return if allow_passwordless && !account
|
|
419
|
+
|
|
385
420
|
unless account && account["password"] && Routes.verify_password_value(ctx, password.to_s, account["password"])
|
|
386
421
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
|
|
387
422
|
end
|
|
@@ -4,6 +4,9 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module BetterAuth
|
|
6
6
|
class RateLimiter
|
|
7
|
+
MISSING_CLIENT_IP_WARNING = "Rate limiting skipped: could not determine client IP address. " \
|
|
8
|
+
"Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed."
|
|
9
|
+
|
|
7
10
|
class MemoryStore
|
|
8
11
|
def initialize
|
|
9
12
|
@entries = {}
|
|
@@ -36,6 +39,7 @@ module BetterAuth
|
|
|
36
39
|
|
|
37
40
|
def initialize
|
|
38
41
|
@memory_store = MemoryStore.new
|
|
42
|
+
@warned_missing_client_ip = false
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def call(request, context, path)
|
|
@@ -43,6 +47,7 @@ module BetterAuth
|
|
|
43
47
|
return unless config[:enabled]
|
|
44
48
|
|
|
45
49
|
ip = client_ip(request, context.options)
|
|
50
|
+
warn_missing_client_ip(context) unless ip
|
|
46
51
|
return unless ip
|
|
47
52
|
|
|
48
53
|
rule = rate_limit_rule(request, context, config, path)
|
|
@@ -213,6 +218,19 @@ module BetterAuth
|
|
|
213
218
|
RequestIP.client_ip(request, options)
|
|
214
219
|
end
|
|
215
220
|
|
|
221
|
+
def warn_missing_client_ip(context)
|
|
222
|
+
return if @warned_missing_client_ip
|
|
223
|
+
return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
|
|
224
|
+
|
|
225
|
+
@warned_missing_client_ip = true
|
|
226
|
+
logger = context.logger
|
|
227
|
+
if logger.respond_to?(:call)
|
|
228
|
+
logger.call(:warn, MISSING_CLIENT_IP_WARNING)
|
|
229
|
+
elsif logger.respond_to?(:warn)
|
|
230
|
+
logger.warn(MISSING_CLIENT_IP_WARNING)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
216
234
|
def matching_plugin_rule(context, path)
|
|
217
235
|
context.options.plugins
|
|
218
236
|
.flat_map { |plugin| Array(plugin[:rate_limit]) }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module RequestState
|
|
5
|
+
THREAD_KEY = :better_auth_request_state_stack
|
|
6
|
+
|
|
7
|
+
State = Struct.new(:ref, :initializer) do
|
|
8
|
+
def get
|
|
9
|
+
store = RequestState.current_store
|
|
10
|
+
store[ref] = initializer.call unless store.key?(ref)
|
|
11
|
+
store[ref]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def set(value)
|
|
15
|
+
RequestState.current_store[ref] = value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def run(store = {}, &block)
|
|
22
|
+
stack.push(store)
|
|
23
|
+
block.call
|
|
24
|
+
ensure
|
|
25
|
+
stack.pop
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def present?
|
|
29
|
+
!stack.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def current_store
|
|
33
|
+
stack.last || raise("No request state found. Please make sure you are calling this function within a `run` callback.")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def define(&initializer)
|
|
37
|
+
State.new(Object.new.freeze, initializer || -> {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stack
|
|
41
|
+
Thread.current[THREAD_KEY] ||= []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -90,10 +90,10 @@ module BetterAuth
|
|
|
90
90
|
Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
|
|
91
91
|
ctx.json({
|
|
92
92
|
accessToken: values["accessToken"],
|
|
93
|
-
refreshToken: values["refreshToken"],
|
|
93
|
+
refreshToken: values["refreshToken"] || refresh_token,
|
|
94
94
|
accessTokenExpiresAt: values["accessTokenExpiresAt"],
|
|
95
|
-
refreshTokenExpiresAt: values["refreshTokenExpiresAt"],
|
|
96
|
-
scope:
|
|
95
|
+
refreshTokenExpiresAt: values["refreshTokenExpiresAt"] || account["refreshTokenExpiresAt"],
|
|
96
|
+
scope: values["scope"] || account["scope"],
|
|
97
97
|
idToken: values["idToken"] || account["idToken"],
|
|
98
98
|
providerId: account["providerId"],
|
|
99
99
|
accountId: account["accountId"]
|
|
@@ -167,7 +167,10 @@ module BetterAuth
|
|
|
167
167
|
def self.update_account_tokens(ctx, account, tokens)
|
|
168
168
|
return nil if account["id"].to_s.empty?
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
data = account_token_update_hash(ctx, tokens)
|
|
171
|
+
return nil if data.empty?
|
|
172
|
+
|
|
173
|
+
ctx.context.internal_adapter.update_account(account["id"], data)
|
|
171
174
|
end
|
|
172
175
|
|
|
173
176
|
def self.token_hash(tokens)
|
|
@@ -183,6 +186,15 @@ module BetterAuth
|
|
|
183
186
|
data
|
|
184
187
|
end
|
|
185
188
|
|
|
189
|
+
def self.account_token_update_hash(ctx, tokens)
|
|
190
|
+
account_storage_fields(token_hash_for_storage(ctx, tokens))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.account_storage_fields(data)
|
|
194
|
+
allowed = %w[accessToken refreshToken idToken accessTokenExpiresAt refreshTokenExpiresAt scope]
|
|
195
|
+
token_hash(data).select { |key, value| allowed.include?(key) && !value.nil? }
|
|
196
|
+
end
|
|
197
|
+
|
|
186
198
|
def self.oauth_token_for_storage(ctx, token)
|
|
187
199
|
return token if token.to_s.empty?
|
|
188
200
|
return token unless ctx.context.options.account[:encrypt_oauth_tokens]
|
|
@@ -14,6 +14,8 @@ module BetterAuth
|
|
|
14
14
|
|
|
15
15
|
body = normalize_hash(ctx.body)
|
|
16
16
|
email = body["email"].to_s.downcase
|
|
17
|
+
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
18
|
+
validate_callback_url!(ctx.context, redirect_to)
|
|
17
19
|
found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
|
|
18
20
|
unless found
|
|
19
21
|
SecureRandom.hex(12)
|
|
@@ -29,7 +31,6 @@ module BetterAuth
|
|
|
29
31
|
expiresAt: Time.now + expires_in.to_i
|
|
30
32
|
)
|
|
31
33
|
|
|
32
|
-
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
33
34
|
callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
|
|
34
35
|
url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
|
|
35
36
|
sender.call({user: found[:user], url: url, token: token}, ctx.request)
|
|
@@ -27,6 +27,8 @@ module BetterAuth
|
|
|
27
27
|
callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
|
|
28
28
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
29
29
|
|
|
30
|
+
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
31
|
+
|
|
30
32
|
unless EMAIL_PATTERN.match?(email)
|
|
31
33
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
|
|
32
34
|
end
|
|
@@ -32,6 +32,7 @@ module BetterAuth
|
|
|
32
32
|
callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
|
|
33
33
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
34
34
|
|
|
35
|
+
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
35
36
|
validate_sign_up_input!(email, password, email_config)
|
|
36
37
|
|
|
37
38
|
ctx.context.adapter.transaction do
|
|
@@ -101,6 +102,13 @@ module BetterAuth
|
|
|
101
102
|
end
|
|
102
103
|
end
|
|
103
104
|
|
|
105
|
+
def self.validate_auth_callback_url!(context, value, label)
|
|
106
|
+
return if value.nil? || value.to_s.empty?
|
|
107
|
+
return if context.trusted_origin?(value.to_s, allow_relative_paths: true)
|
|
108
|
+
|
|
109
|
+
raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
|
|
110
|
+
end
|
|
111
|
+
|
|
104
112
|
def self.create_sign_up_user(ctx, body, email, name, image)
|
|
105
113
|
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
106
114
|
additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
|
|
@@ -228,6 +228,12 @@ module BetterAuth
|
|
|
228
228
|
|
|
229
229
|
if existing && existing[:linked_account]
|
|
230
230
|
user = existing[:user]
|
|
231
|
+
if ctx.context.options.account[:update_account_on_sign_in] != false
|
|
232
|
+
update_data = account_storage_fields(account_info)
|
|
233
|
+
ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data) unless update_data.empty?
|
|
234
|
+
end
|
|
235
|
+
verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
|
|
236
|
+
user = verified_user if verified_user
|
|
231
237
|
new_user = false
|
|
232
238
|
elsif existing
|
|
233
239
|
unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
|
|
@@ -235,6 +241,8 @@ module BetterAuth
|
|
|
235
241
|
end
|
|
236
242
|
user = existing[:user]
|
|
237
243
|
ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
|
|
244
|
+
verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
|
|
245
|
+
user = verified_user if verified_user
|
|
238
246
|
new_user = false
|
|
239
247
|
else
|
|
240
248
|
return {error: "signup disabled"} if disable_sign_up
|
|
@@ -251,6 +259,7 @@ module BetterAuth
|
|
|
251
259
|
user = created[:user]
|
|
252
260
|
new_user = true
|
|
253
261
|
end
|
|
262
|
+
user = override_social_user_info(ctx, user, user_info) if existing && provider_override_user_info_on_sign_in?(provider_id, ctx.context)
|
|
254
263
|
|
|
255
264
|
session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
|
|
256
265
|
{session: session, user: user, new_user: new_user}
|
|
@@ -318,6 +327,27 @@ module BetterAuth
|
|
|
318
327
|
{status: true}
|
|
319
328
|
end
|
|
320
329
|
|
|
330
|
+
def self.provider_override_user_info_on_sign_in?(provider_id, context)
|
|
331
|
+
provider = social_provider(context, provider_id)
|
|
332
|
+
!!(fetch_value(provider, "overrideUserInfoOnSignIn") || fetch_value(fetch_value(provider, "options") || {}, "overrideUserInfoOnSignIn"))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.override_social_user_info(ctx, user, user_info)
|
|
336
|
+
email = fetch_value(user_info, "email").to_s.downcase
|
|
337
|
+
email_verified = if email == user["email"].to_s.downcase
|
|
338
|
+
!!(user["emailVerified"] || fetch_value(user_info, "emailVerified"))
|
|
339
|
+
else
|
|
340
|
+
!!fetch_value(user_info, "emailVerified")
|
|
341
|
+
end
|
|
342
|
+
update = {
|
|
343
|
+
"email" => email,
|
|
344
|
+
"name" => fetch_value(user_info, "name").to_s,
|
|
345
|
+
"image" => fetch_value(user_info, "image"),
|
|
346
|
+
"emailVerified" => email_verified
|
|
347
|
+
}.reject { |_key, value| value.nil? }
|
|
348
|
+
ctx.context.internal_adapter.update_user(user["id"], update) || user
|
|
349
|
+
end
|
|
350
|
+
|
|
321
351
|
def self.safe_additional_state(body)
|
|
322
352
|
additional = body["additionalData"] || body["additional_data"]
|
|
323
353
|
return {} unless additional.is_a?(Hash)
|
|
@@ -79,10 +79,11 @@ module BetterAuth
|
|
|
79
79
|
delete_user_by_token!(ctx, session, body["token"])
|
|
80
80
|
elsif sender
|
|
81
81
|
token = SecureRandom.hex(16)
|
|
82
|
+
expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
|
|
82
83
|
ctx.context.internal_adapter.create_verification_value(
|
|
83
84
|
identifier: "delete-account-#{token}",
|
|
84
85
|
value: session[:user]["id"],
|
|
85
|
-
expiresAt: Time.now +
|
|
86
|
+
expiresAt: Time.now + expires_in.to_i
|
|
86
87
|
)
|
|
87
88
|
sender.call({user: session[:user], token: token}, ctx.request)
|
|
88
89
|
next ctx.json({success: true, message: "Verification email sent"})
|
|
@@ -120,9 +121,11 @@ module BetterAuth
|
|
|
120
121
|
new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
|
|
121
122
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
|
|
122
123
|
raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
|
|
123
|
-
|
|
124
|
+
existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
|
|
124
125
|
|
|
125
126
|
if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
|
|
127
|
+
next ctx.json({status: true}) if existing_target
|
|
128
|
+
|
|
126
129
|
updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
|
|
127
130
|
Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
|
|
128
131
|
next ctx.json({status: true})
|
|
@@ -130,6 +133,7 @@ module BetterAuth
|
|
|
130
133
|
|
|
131
134
|
sender = ctx.context.options.email_verification[:send_verification_email]
|
|
132
135
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
|
|
136
|
+
next ctx.json({status: true}) if existing_target
|
|
133
137
|
|
|
134
138
|
token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
|
|
135
139
|
sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
|
|
@@ -148,7 +152,9 @@ module BetterAuth
|
|
|
148
152
|
def self.delete_current_user!(ctx, session)
|
|
149
153
|
config = ctx.context.options.user[:delete_user] || {}
|
|
150
154
|
call_option(config[:before_delete], session[:user], ctx.request)
|
|
151
|
-
ctx.context.internal_adapter.delete_user(session[:user]["id"])
|
|
155
|
+
deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"])
|
|
156
|
+
raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false
|
|
157
|
+
|
|
152
158
|
ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
|
|
153
159
|
Cookies.delete_session_cookie(ctx)
|
|
154
160
|
call_option(config[:after_delete], session[:user], ctx.request)
|
data/lib/better_auth/session.rb
CHANGED
|
@@ -51,7 +51,7 @@ module BetterAuth
|
|
|
51
51
|
return nil if payload["session"]["token"] && payload["session"]["token"] != token
|
|
52
52
|
|
|
53
53
|
result = {session: payload["session"], user: payload["user"]}
|
|
54
|
-
|
|
54
|
+
result = refresh_cached_session(ctx, result) if should_refresh_cookie_cache?(config, payload)
|
|
55
55
|
result
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -89,6 +89,17 @@ module BetterAuth
|
|
|
89
89
|
refreshed
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
def refresh_cached_session(ctx, result)
|
|
93
|
+
now = Time.now
|
|
94
|
+
session = stringify_keys(result[:session]).merge(
|
|
95
|
+
"expiresAt" => now + ctx.context.session_config[:expires_in].to_i,
|
|
96
|
+
"updatedAt" => now
|
|
97
|
+
)
|
|
98
|
+
refreshed = {session: session, user: result[:user]}
|
|
99
|
+
Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
|
|
100
|
+
refreshed
|
|
101
|
+
end
|
|
102
|
+
|
|
92
103
|
def should_refresh_cookie_cache?(config, payload)
|
|
93
104
|
refresh_cache = config[:refresh_cache]
|
|
94
105
|
return false if refresh_cache == false || refresh_cache.nil?
|