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.
@@ -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,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
- 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"]
492
510
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
493
- member = require_member!(ctx, session[:user]["id"], organization_id)
494
- 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)})
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[: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)
@@ -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: 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]
@@ -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 + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
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
- raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]) if ctx.context.internal_adapter.find_user_by_email(new_email)
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)
@@ -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
- Cookies.set_cookie_cache(ctx, result, false) if should_refresh_cookie_cache?(config, payload)
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?