better_auth 0.2.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +173 -20
  5. data/lib/better_auth/adapters/memory.rb +61 -12
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +44 -3
  8. data/lib/better_auth/api.rb +7 -2
  9. data/lib/better_auth/async.rb +70 -0
  10. data/lib/better_auth/context.rb +2 -1
  11. data/lib/better_auth/database_hooks.rb +3 -3
  12. data/lib/better_auth/deprecate.rb +28 -0
  13. data/lib/better_auth/endpoint.rb +5 -2
  14. data/lib/better_auth/host.rb +166 -0
  15. data/lib/better_auth/instrumentation.rb +74 -0
  16. data/lib/better_auth/logger.rb +31 -0
  17. data/lib/better_auth/middleware/origin_check.rb +2 -2
  18. data/lib/better_auth/oauth2.rb +94 -0
  19. data/lib/better_auth/plugin.rb +14 -1
  20. data/lib/better_auth/plugins/email_otp.rb +16 -5
  21. data/lib/better_auth/plugins/generic_oauth.rb +14 -28
  22. data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
  23. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  24. data/lib/better_auth/plugins/organization.rb +56 -20
  25. data/lib/better_auth/plugins/two_factor.rb +53 -18
  26. data/lib/better_auth/rate_limiter.rb +37 -2
  27. data/lib/better_auth/request_state.rb +44 -0
  28. data/lib/better_auth/router.rb +14 -1
  29. data/lib/better_auth/routes/account.rb +16 -4
  30. data/lib/better_auth/routes/email_verification.rb +5 -2
  31. data/lib/better_auth/routes/password.rb +21 -1
  32. data/lib/better_auth/routes/session.rb +27 -4
  33. data/lib/better_auth/routes/sign_in.rb +3 -1
  34. data/lib/better_auth/routes/sign_up.rb +60 -1
  35. data/lib/better_auth/routes/social.rb +231 -22
  36. data/lib/better_auth/routes/user.rb +23 -5
  37. data/lib/better_auth/schema/sql.rb +11 -0
  38. data/lib/better_auth/schema.rb +16 -0
  39. data/lib/better_auth/session.rb +12 -1
  40. data/lib/better_auth/social_providers/apple.rb +44 -8
  41. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  42. data/lib/better_auth/social_providers/base.rb +262 -4
  43. data/lib/better_auth/social_providers/cognito.rb +32 -0
  44. data/lib/better_auth/social_providers/discord.rb +27 -5
  45. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  46. data/lib/better_auth/social_providers/facebook.rb +35 -0
  47. data/lib/better_auth/social_providers/figma.rb +31 -0
  48. data/lib/better_auth/social_providers/github.rb +21 -6
  49. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  50. data/lib/better_auth/social_providers/google.rb +38 -13
  51. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  52. data/lib/better_auth/social_providers/kakao.rb +32 -0
  53. data/lib/better_auth/social_providers/kick.rb +32 -0
  54. data/lib/better_auth/social_providers/line.rb +33 -0
  55. data/lib/better_auth/social_providers/linear.rb +44 -0
  56. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  57. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  58. data/lib/better_auth/social_providers/naver.rb +31 -0
  59. data/lib/better_auth/social_providers/notion.rb +33 -0
  60. data/lib/better_auth/social_providers/paybin.rb +31 -0
  61. data/lib/better_auth/social_providers/paypal.rb +36 -0
  62. data/lib/better_auth/social_providers/polar.rb +31 -0
  63. data/lib/better_auth/social_providers/railway.rb +49 -0
  64. data/lib/better_auth/social_providers/reddit.rb +32 -0
  65. data/lib/better_auth/social_providers/roblox.rb +31 -0
  66. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  67. data/lib/better_auth/social_providers/slack.rb +30 -0
  68. data/lib/better_auth/social_providers/spotify.rb +31 -0
  69. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  70. data/lib/better_auth/social_providers/twitch.rb +39 -0
  71. data/lib/better_auth/social_providers/twitter.rb +32 -0
  72. data/lib/better_auth/social_providers/vercel.rb +47 -0
  73. data/lib/better_auth/social_providers/vk.rb +34 -0
  74. data/lib/better_auth/social_providers/wechat.rb +104 -0
  75. data/lib/better_auth/social_providers/zoom.rb +31 -0
  76. data/lib/better_auth/social_providers.rb +29 -0
  77. data/lib/better_auth/url_helpers.rb +195 -0
  78. data/lib/better_auth/version.rb +1 -1
  79. data/lib/better_auth.rb +8 -1
  80. metadata +38 -15
@@ -10,6 +10,7 @@ module BetterAuth
10
10
  organization: {
11
11
  model_name: "organizations",
12
12
  fields: {
13
+ id: {type: "string", required: true},
13
14
  name: {type: "string", required: true, sortable: true},
14
15
  slug: {type: "string", required: true, unique: true, sortable: true, index: true},
15
16
  logo: {type: "string", required: false},
@@ -21,6 +22,7 @@ module BetterAuth
21
22
  member: {
22
23
  model_name: "members",
23
24
  fields: {
25
+ id: {type: "string", required: true},
24
26
  organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
25
27
  userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
26
28
  role: {type: "string", required: true, default_value: "member", sortable: true},
@@ -30,6 +32,7 @@ module BetterAuth
30
32
  invitation: {
31
33
  model_name: "invitations",
32
34
  fields: {
35
+ id: {type: "string", required: true},
33
36
  organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
34
37
  email: {type: "string", required: true, sortable: true, index: true},
35
38
  role: {type: "string", required: true, sortable: true},
@@ -50,6 +53,7 @@ module BetterAuth
50
53
  schema[:team] = {
51
54
  model_name: "teams",
52
55
  fields: {
56
+ id: {type: "string", required: true},
53
57
  name: {type: "string", required: true},
54
58
  organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
55
59
  createdAt: {type: "date", required: true, default_value: -> { Time.now }},
@@ -59,6 +63,7 @@ module BetterAuth
59
63
  schema[:teamMember] = {
60
64
  model_name: "team_members",
61
65
  fields: {
66
+ id: {type: "string", required: true},
62
67
  teamId: {type: "string", required: true, references: {model: "team", field: "id"}, index: true},
63
68
  userId: {type: "string", required: true, references: {model: "user", field: "id"}, index: true},
64
69
  createdAt: {type: "date", required: false, default_value: -> { Time.now }}
@@ -72,6 +77,7 @@ module BetterAuth
72
77
  schema[:organizationRole] = {
73
78
  model_name: "organization_roles",
74
79
  fields: {
80
+ id: {type: "string", required: true},
75
81
  organizationId: {type: "string", required: true, references: {model: "organization", field: "id"}, index: true},
76
82
  role: {type: "string", required: true, index: true},
77
83
  permission: {type: "string", required: true},
@@ -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
- next ctx.json(nil) unless organization
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
- organizationId: organization["id"],
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,11 +381,14 @@ 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})
371
388
  end
372
389
  updated = ctx.context.adapter.update(model: "invitation", where: [{field: "id", value: invitation["id"]}], update: {status: "accepted"})
390
+ organization = organization_by_id(ctx, invitation["organizationId"])
391
+ run_org_hook(config, :after_accept_invitation, {invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
373
392
  ctx.json({invitation: invitation_wire(ctx, updated), member: member_wire(ctx, member)})
374
393
  end
375
394
  end
@@ -452,8 +471,11 @@ module BetterAuth
452
471
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("MEMBER_NOT_FOUND")) unless member
453
472
  require_org_permission!(ctx, config, session, member["organizationId"], {member: ["delete"]}, ORGANIZATION_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_MEMBER"))
454
473
  ensure_not_last_owner!(ctx, member)
474
+ organization = organization_by_id(ctx, member["organizationId"])
475
+ user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
455
476
  ctx.context.adapter.delete(model: "member", where: [{field: "id", value: member["id"]}])
456
477
  ctx.context.adapter.delete_many(model: "teamMember", where: [{field: "userId", value: member["userId"]}]) if org_truthy?(config.dig(:teams, :enabled))
478
+ run_org_hook(config, :after_remove_member, {member: member_wire(ctx, member), user: user, organization: organization_wire(ctx, organization)}, ctx)
457
479
  ctx.json({status: true})
458
480
  end
459
481
  end
@@ -483,10 +505,12 @@ module BetterAuth
483
505
  def organization_get_active_member_role_endpoint(_config)
484
506
  Endpoint.new(path: "/organization/get-active-member-role", method: "GET") do |ctx|
485
507
  session = Routes.current_session(ctx)
486
- organization_id = normalize_hash(ctx.query)[:organization_id] || session[:session]["activeOrganizationId"]
508
+ query = normalize_hash(ctx.query)
509
+ organization_id = query[:organization_id] || session[:session]["activeOrganizationId"]
487
510
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
488
- member = require_member!(ctx, session[:user]["id"], organization_id)
489
- ctx.json({role: member["role"]})
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)})
490
514
  end
491
515
  end
492
516
 
@@ -506,7 +530,7 @@ module BetterAuth
506
530
  Endpoint.new(path: "/organization/list-members", method: "GET") do |ctx|
507
531
  session = Routes.current_session(ctx)
508
532
  query = normalize_hash(ctx.query)
509
- 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"]
510
534
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
511
535
  require_member!(ctx, session[:user]["id"], organization_id)
512
536
  ctx.json(list_members_for(ctx, organization_id, query))
@@ -538,7 +562,7 @@ module BetterAuth
538
562
  end
539
563
  team_data = {organizationId: organization_id, name: body[:name].to_s, createdAt: Time.now}.merge(additional_input(body, :organization_id, :name))
540
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))
541
- 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)
542
566
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
543
567
  run_org_hook(config, :after_create_team, {team: team_wire(ctx, team), user: session[:user], organization: organization_wire(ctx, organization)}, ctx)
544
568
  ctx.json(team_wire(ctx, team))
@@ -859,7 +883,7 @@ module BetterAuth
859
883
  where: where,
860
884
  limit: query[:limit],
861
885
  offset: query[:offset],
862
- 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
863
887
  )
864
888
  {
865
889
  members: members.map { |entry| member_wire(ctx, entry) },
@@ -867,6 +891,18 @@ module BetterAuth
867
891
  }
868
892
  end
869
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
+
870
906
  def member_wire(ctx, member)
871
907
  data = Schema.parse_output(ctx.context.options, "member", member)
872
908
  user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
@@ -910,7 +946,7 @@ module BetterAuth
910
946
  team = if custom.respond_to?(:call)
911
947
  custom.call(organization_wire(ctx, organization), ctx)
912
948
  else
913
- ctx.context.adapter.create(model: "team", data: team_data)
949
+ ctx.context.adapter.create(model: "team", data: team_data, force_allow_id: true)
914
950
  end
915
951
  ctx.context.adapter.create(model: "teamMember", data: {teamId: team["id"], userId: session[:user]["id"], createdAt: Time.now})
916
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[:schema]),
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.context.adapter.delete_many(model: config[:two_factor_table], where: [{field: "userId", value: session[:user]["id"]}])
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: config[:two_factor_table],
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: config[:two_factor_table], where: [{field: "userId", value: updated_user["id"]}])
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 !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
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: config[:two_factor_table],
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: config[:two_factor_table], where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
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(custom_schema = nil)
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: config[:two_factor_table], where: [{field: "userId", value: user_id}])
403
+ ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
381
404
  end
382
405
 
383
- def two_factor_check_password!(ctx, user_id, password)
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)
@@ -148,18 +153,26 @@ module BetterAuth
148
153
  def read_storage((type, storage), key)
149
154
  data = storage.get(key)
150
155
  data = JSON.parse(data) if type == :secondary && data.is_a?(String)
151
- symbolize_keys(data)
156
+ normalize_rate_limit_data(symbolize_keys(data))
152
157
  rescue JSON::ParserError
153
158
  nil
154
159
  end
155
160
 
156
161
  def write_storage((type, storage), key, data, ttl:, update:)
157
- value = (type == :secondary) ? JSON.generate(data) : data
162
+ value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
158
163
  return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
159
164
 
160
165
  call_storage_set(storage, key, value, ttl: ttl, update: update)
161
166
  end
162
167
 
168
+ def secondary_storage_data(data)
169
+ {
170
+ key: data[:key],
171
+ count: data[:count],
172
+ lastRequest: (data[:last_request].to_f * 1000).to_i
173
+ }
174
+ end
175
+
163
176
  def call_secondary_storage_set(storage, key, value, ttl:, update:)
164
177
  storage.set(key, value, ttl)
165
178
  rescue ArgumentError
@@ -188,6 +201,15 @@ module BetterAuth
188
201
  end
189
202
  end
190
203
 
204
+ def normalize_rate_limit_data(data)
205
+ return data unless data.is_a?(Hash)
206
+
207
+ last_request = data[:last_request]
208
+ return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
209
+
210
+ data.merge(last_request: last_request / 1000.0)
211
+ end
212
+
191
213
  def rate_limit_key(ip, path)
192
214
  "#{ip}|#{path}"
193
215
  end
@@ -196,6 +218,19 @@ module BetterAuth
196
218
  RequestIP.client_ip(request, options)
197
219
  end
198
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
+
199
234
  def matching_plugin_rule(context, path)
200
235
  context.options.plugins
201
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
@@ -64,6 +64,7 @@ module BetterAuth
64
64
 
65
65
  body = parse_body(request)
66
66
  endpoint_context = build_endpoint_context(request, route_path, query, body, params)
67
+ return run_on_response_chain(forbidden) if server_only?(endpoint)
67
68
 
68
69
  response = @origin_check.call(endpoint_context)
69
70
  return run_on_response_chain(response) if response
@@ -166,13 +167,17 @@ module BetterAuth
166
167
  request.body.rewind
167
168
  return {} if raw.empty?
168
169
 
169
- if request.media_type == "application/json"
170
+ if json_media_type?(request.media_type)
170
171
  JSON.parse(raw)
171
172
  else
172
173
  request.POST
173
174
  end
174
175
  end
175
176
 
177
+ def json_media_type?(media_type)
178
+ media_type == "application/json" || media_type.to_s.end_with?("+json")
179
+ end
180
+
176
181
  def allowed_media_type?(request, endpoint)
177
182
  return true unless request_body_method?(request.request_method)
178
183
  return true if request.media_type.nil? || request.media_type.empty?
@@ -350,6 +355,14 @@ module BetterAuth
350
355
  [415, {"content-type" => "application/json"}, [JSON.generate({error: "Unsupported Media Type"})]]
351
356
  end
352
357
 
358
+ def forbidden
359
+ [403, {"content-type" => "application/json"}, [JSON.generate({error: "Forbidden"})]]
360
+ end
361
+
362
+ def server_only?(endpoint)
363
+ endpoint.metadata[:server_only] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata["SERVER_ONLY"]
364
+ end
365
+
353
366
  def error_response(error, headers: {})
354
367
  Endpoint::Result.new(
355
368
  response: error.to_h,
@@ -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: Array(values["scopes"]).join(","),
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
- ctx.context.internal_adapter.update_account(account["id"], token_hash_for_storage(ctx, tokens))
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]
@@ -35,6 +35,7 @@ module BetterAuth
35
35
  Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
36
36
  token = fetch_value(ctx.query, "token").to_s
37
37
  callback_url = fetch_value(ctx.query, "callbackURL")
38
+ validate_callback_url!(ctx.context, callback_url)
38
39
  payload = verify_email_token(ctx, token, callback_url)
39
40
  email = payload["email"].to_s.downcase
40
41
  update_to = payload["updateTo"] || payload["update_to"]
@@ -43,8 +44,10 @@ module BetterAuth
43
44
 
44
45
  user = user_data[:user]
45
46
  if update_to
46
- updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
47
- set_verified_session_cookie(ctx, updated || user.merge("email" => update_to, "emailVerified" => true))
47
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
48
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
49
+ send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
50
+ set_verified_session_cookie(ctx, updated_user)
48
51
  next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
49
52
  end
50
53