better_auth 0.7.0 → 0.10.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -1
  3. data/README.md +6 -6
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/mongodb.rb +3 -3
  6. data/lib/better_auth/adapters/sql.rb +139 -57
  7. data/lib/better_auth/auth.rb +68 -1
  8. data/lib/better_auth/configuration.rb +15 -10
  9. data/lib/better_auth/context.rb +1 -1
  10. data/lib/better_auth/cookies.rb +11 -3
  11. data/lib/better_auth/doctor.rb +97 -0
  12. data/lib/better_auth/endpoint.rb +88 -5
  13. data/lib/better_auth/env.rb +38 -0
  14. data/lib/better_auth/http_client.rb +46 -0
  15. data/lib/better_auth/migration_plan.rb +15 -0
  16. data/lib/better_auth/oauth2.rb +1 -1
  17. data/lib/better_auth/plugins/admin.rb +6 -1
  18. data/lib/better_auth/plugins/anonymous.rb +2 -0
  19. data/lib/better_auth/plugins/captcha.rb +1 -1
  20. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  21. data/lib/better_auth/plugins/dub.rb +8 -0
  22. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  23. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  24. data/lib/better_auth/plugins/jwt.rb +10 -3
  25. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  26. data/lib/better_auth/plugins/mcp.rb +41 -0
  27. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  28. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  29. data/lib/better_auth/plugins/one_tap.rb +17 -5
  30. data/lib/better_auth/plugins/open_api.rb +42 -2
  31. data/lib/better_auth/plugins/organization.rb +122 -11
  32. data/lib/better_auth/plugins/phone_number.rb +1 -1
  33. data/lib/better_auth/plugins/two_factor.rb +21 -0
  34. data/lib/better_auth/rate_limiter.rb +7 -2
  35. data/lib/better_auth/routes/account.rb +4 -0
  36. data/lib/better_auth/routes/email_verification.rb +5 -1
  37. data/lib/better_auth/routes/password.rb +1 -0
  38. data/lib/better_auth/routes/social.rb +29 -1
  39. data/lib/better_auth/routes/user.rb +6 -2
  40. data/lib/better_auth/schema/sql.rb +104 -15
  41. data/lib/better_auth/schema.rb +35 -2
  42. data/lib/better_auth/session.rb +2 -1
  43. data/lib/better_auth/social_providers/base.rb +4 -9
  44. data/lib/better_auth/social_providers/facebook.rb +1 -1
  45. data/lib/better_auth/social_providers/github.rb +2 -0
  46. data/lib/better_auth/social_providers/line.rb +1 -1
  47. data/lib/better_auth/social_providers/paypal.rb +1 -1
  48. data/lib/better_auth/sql_migration.rb +566 -0
  49. data/lib/better_auth/url_helpers.rb +1 -1
  50. data/lib/better_auth/version.rb +1 -1
  51. data/lib/better_auth.rb +4 -0
  52. metadata +15 -8
@@ -441,6 +441,8 @@ module BetterAuth
441
441
  openapi: {
442
442
  operationId: "oauth2EndSession",
443
443
  description: "RP-Initiated Logout endpoint",
444
+ parameters: oidc_end_session_schema[:properties].keys.map { |name| OpenAPI.query_parameter(name.to_s, schema: oidc_end_session_schema[:properties][name]) },
445
+ requestBody: OpenAPI.json_request_body(oidc_end_session_schema, required: false),
444
446
  responses: {
445
447
  "302" => {description: "Redirects after clearing the session cookie"}
446
448
  }
@@ -470,6 +472,7 @@ module BetterAuth
470
472
  openapi: {
471
473
  operationId: operation_id,
472
474
  description: description,
475
+ requestBody: oidc_request_body_for(operation_id),
473
476
  responses: {
474
477
  "200" => OpenAPI.json_response(response_description, response_schema)
475
478
  }
@@ -477,6 +480,62 @@ module BetterAuth
477
480
  }
478
481
  end
479
482
 
483
+ def oidc_request_body_for(operation_id)
484
+ schema = case operation_id
485
+ when "registerOAuthApplication", "updateOAuthApplication"
486
+ oidc_client_registration_schema
487
+ when "rotateOAuthApplicationSecret"
488
+ OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
489
+ when "oauth2Consent"
490
+ OpenAPI.object_schema({consent_code: {type: "string"}, accept: {type: "boolean"}}, required: ["consent_code"])
491
+ when "oauth2Token"
492
+ OpenAPI.object_schema(
493
+ {
494
+ grant_type: {type: "string", enum: [OAuthProtocol::AUTH_CODE_GRANT, OAuthProtocol::REFRESH_GRANT]},
495
+ code: {type: "string"},
496
+ redirect_uri: {type: "string", format: "uri"},
497
+ code_verifier: {type: "string"},
498
+ client_id: {type: "string"},
499
+ client_secret: {type: "string"},
500
+ refresh_token: {type: "string"},
501
+ scope: {type: "string"}
502
+ },
503
+ required: ["grant_type"]
504
+ )
505
+ when "oauth2Introspect", "oauth2Revoke"
506
+ OpenAPI.object_schema({token: {type: "string"}, token_type_hint: {type: "string", enum: ["access_token", "refresh_token"]}}, required: ["token"])
507
+ end
508
+ schema ? OpenAPI.json_request_body(schema) : nil
509
+ end
510
+
511
+ def oidc_client_registration_schema
512
+ OpenAPI.object_schema(
513
+ {
514
+ redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
515
+ post_logout_redirect_uris: {type: "array", items: {type: "string", format: "uri"}},
516
+ client_name: {type: "string"},
517
+ client_uri: {type: "string", format: "uri"},
518
+ logo_uri: {type: "string", format: "uri"},
519
+ grant_types: {type: "array", items: {type: "string"}},
520
+ response_types: {type: "array", items: {type: "string"}},
521
+ scope: {type: "string"},
522
+ scopes: {type: "array", items: {type: "string"}},
523
+ metadata: {type: "object", additionalProperties: true}
524
+ }
525
+ )
526
+ end
527
+
528
+ def oidc_end_session_schema
529
+ OpenAPI.object_schema(
530
+ {
531
+ id_token_hint: {type: "string"},
532
+ client_id: {type: "string"},
533
+ post_logout_redirect_uri: {type: "string", format: "uri"},
534
+ state: {type: "string"}
535
+ }
536
+ )
537
+ end
538
+
480
539
  def oidc_client_schema
481
540
  OpenAPI.object_schema(
482
541
  {
@@ -541,7 +600,7 @@ module BetterAuth
541
600
  def oidc_provider_schema
542
601
  {
543
602
  oauthApplication: {
544
- modelName: "oauthApplication",
603
+ model_name: "oauth_applications",
545
604
  fields: {
546
605
  name: {type: "string"},
547
606
  icon: {type: "string", required: false},
@@ -565,7 +624,7 @@ module BetterAuth
565
624
  }
566
625
  },
567
626
  oauthAccessToken: {
568
- modelName: "oauthAccessToken",
627
+ model_name: "oauth_access_tokens",
569
628
  fields: {
570
629
  accessToken: {type: "string", unique: true, required: false},
571
630
  token: {type: "string", unique: true, required: false},
@@ -583,7 +642,7 @@ module BetterAuth
583
642
  }
584
643
  },
585
644
  oauthConsent: {
586
- modelName: "oauthConsent",
645
+ model_name: "oauth_consents",
587
646
  fields: {
588
647
  clientId: {type: "string", required: true},
589
648
  userId: {type: "string", required: true},
@@ -32,6 +32,14 @@ module BetterAuth
32
32
  operationId: "oneTapCallback",
33
33
  summary: "One tap callback",
34
34
  description: "Use this endpoint to authenticate with Google One Tap",
35
+ requestBody: OpenAPI.json_request_body(
36
+ OpenAPI.object_schema(
37
+ {
38
+ id_token: {type: "string", description: "Google One Tap ID token"}
39
+ },
40
+ required: ["id_token"]
41
+ )
42
+ ),
35
43
  responses: {
36
44
  "200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair)
37
45
  }
@@ -105,16 +113,20 @@ module BetterAuth
105
113
  options[:aud] = audience
106
114
  options[:verify_aud] = true
107
115
  end
108
- payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
116
+ payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
109
117
  payload
110
118
  end
111
119
 
112
120
  def one_tap_google_jwks
113
- uri = URI("https://www.googleapis.com/oauth2/v3/certs")
114
- response = Net::HTTP.get_response(uri)
115
- raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)
121
+ cached = @one_tap_google_jwks_cache
122
+ return cached[:jwks] if cached && cached[:expires_at] > Time.now
116
123
 
117
- JWT::JWK::Set.new(JSON.parse(response.body))
124
+ payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs")
125
+ raise "Unable to fetch Google JWKS" unless payload
126
+
127
+ jwks = ::JWT::JWK::Set.new(payload)
128
+ @one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300}
129
+ jwks
118
130
  end
119
131
 
120
132
  def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
@@ -128,6 +128,21 @@ module BetterAuth
128
128
  }
129
129
  end
130
130
 
131
+ def default_request_body
132
+ {
133
+ required: true,
134
+ content: {
135
+ "application/json" => {
136
+ schema: {
137
+ type: "object",
138
+ properties: {},
139
+ additionalProperties: true
140
+ }
141
+ }
142
+ }
143
+ }
144
+ end
145
+
131
146
  def responses(responses = nil)
132
147
  {"200" => success_response}.merge(default_error_responses).merge(responses || {})
133
148
  end
@@ -142,6 +157,17 @@ module BetterAuth
142
157
  )
143
158
  end
144
159
 
160
+ def default_success_response
161
+ json_response(
162
+ "Success",
163
+ {
164
+ type: "object",
165
+ properties: {},
166
+ additionalProperties: true
167
+ }
168
+ )
169
+ end
170
+
145
171
  def default_error_responses
146
172
  {
147
173
  "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
@@ -168,10 +194,16 @@ module BetterAuth
168
194
 
169
195
  def default_metadata(path, methods)
170
196
  method = Array(methods).reject { |value| value.to_s == "*" }.first.to_s.upcase
171
- {
197
+ metadata = {
172
198
  operationId: operation_id(path, method),
173
- description: "#{method} #{path}"
199
+ description: "Execute the #{operation_id(path, method)} endpoint.",
200
+ parameters: default_path_parameters(path),
201
+ responses: {
202
+ "200" => default_success_response
203
+ }
174
204
  }
205
+ metadata[:requestBody] = default_request_body if %w[POST PUT PATCH].include?(method)
206
+ metadata
175
207
  end
176
208
 
177
209
  def operation_id(path, method)
@@ -183,6 +215,14 @@ module BetterAuth
183
215
 
184
216
  "#{method.to_s.downcase}#{base}"
185
217
  end
218
+
219
+ def default_path_parameters(path)
220
+ path.to_s.split("/").filter_map do |part|
221
+ next unless part.start_with?(":")
222
+
223
+ path_parameter(part.delete_prefix(":"))
224
+ end
225
+ end
186
226
  end
187
227
 
188
228
  module Plugins
@@ -527,14 +527,14 @@ module BetterAuth
527
527
  end
528
528
  end
529
529
 
530
- def organization_list_members_endpoint(_config)
530
+ def organization_list_members_endpoint(config)
531
531
  Endpoint.new(path: "/organization/list-members", method: "GET", metadata: organization_openapi("listOrganizationMembers", "List organization members", response: organization_members_response_schema)) do |ctx|
532
532
  session = Routes.current_session(ctx)
533
533
  query = normalize_hash(ctx.query)
534
534
  organization_id = query[:organization_id] || organization_by_slug(ctx, query[:organization_slug])&.fetch("id") || session[:session]["activeOrganizationId"]
535
535
  raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("NO_ACTIVE_ORGANIZATION")) unless organization_id
536
536
  require_member!(ctx, session[:user]["id"], organization_id)
537
- ctx.json(list_members_for(ctx, organization_id, query))
537
+ ctx.json(list_members_for(ctx, organization_id, query, config, session[:user]))
538
538
  end
539
539
  end
540
540
 
@@ -768,6 +768,7 @@ module BetterAuth
768
768
  end
769
769
 
770
770
  def organization_openapi(operation_id, description, response:, response_description: "Success", request: nil, required: [], parameters: nil)
771
+ request ||= organization_request_schema(operation_id)
771
772
  openapi = {
772
773
  operationId: operation_id,
773
774
  description: description,
@@ -781,6 +782,68 @@ module BetterAuth
781
782
  {openapi: openapi}
782
783
  end
783
784
 
785
+ def organization_request_schema(operation_id)
786
+ string = {type: "string"}
787
+ boolean = {type: "boolean"}
788
+ array = ->(items = string) { {type: "array", items: items} }
789
+ object = {type: "object", additionalProperties: true}
790
+ {
791
+ "createOrganization" => {
792
+ name: string,
793
+ slug: string,
794
+ logo: string,
795
+ metadata: object,
796
+ userId: string,
797
+ user_id: string,
798
+ keepCurrentActiveOrganization: boolean,
799
+ keep_current_active_organization: boolean
800
+ },
801
+ "checkOrganizationSlug" => {slug: string},
802
+ "updateOrganization" => {
803
+ organizationId: string,
804
+ organization_id: string,
805
+ organizationSlug: string,
806
+ organization_slug: string,
807
+ data: object,
808
+ name: string,
809
+ slug: string,
810
+ logo: string,
811
+ metadata: object
812
+ },
813
+ "deleteOrganization" => {organizationId: string, organization_id: string, organizationSlug: string, organization_slug: string},
814
+ "setActiveOrganization" => {organizationId: string, organization_id: string, organizationSlug: string, organization_slug: string},
815
+ "createOrganizationInvitation" => {
816
+ organizationId: string,
817
+ organization_id: string,
818
+ organizationSlug: string,
819
+ organization_slug: string,
820
+ email: {type: "string", format: "email"},
821
+ role: {oneOf: [string, array.call]},
822
+ teamId: string,
823
+ team_id: string,
824
+ teamIds: array.call,
825
+ team_ids: array.call
826
+ },
827
+ "acceptOrganizationInvitation" => {invitationId: string, invitation_id: string, id: string},
828
+ "rejectOrganizationInvitation" => {invitationId: string, invitation_id: string},
829
+ "cancelOrganizationInvitation" => {invitationId: string, invitation_id: string},
830
+ "addOrganizationMember" => {organizationId: string, organization_id: string, userId: string, user_id: string, role: {oneOf: [string, array.call]}},
831
+ "removeOrganizationMember" => {memberId: string, member_id: string, userId: string, user_id: string, organizationId: string, organization_id: string},
832
+ "updateOrganizationMemberRole" => {memberId: string, member_id: string, userId: string, user_id: string, organizationId: string, organization_id: string, role: {oneOf: [string, array.call]}},
833
+ "leaveOrganization" => {organizationId: string, organization_id: string},
834
+ "hasOrganizationPermission" => {organizationId: string, organization_id: string, permission: object, permissions: object},
835
+ "createOrganizationTeam" => {organizationId: string, organization_id: string, name: string},
836
+ "updateOrganizationTeam" => {teamId: string, team_id: string, name: string},
837
+ "removeOrganizationTeam" => {teamId: string, team_id: string},
838
+ "setActiveOrganizationTeam" => {teamId: string, team_id: string},
839
+ "addTeamMember" => {teamId: string, team_id: string, userId: string, user_id: string},
840
+ "removeTeamMember" => {teamId: string, team_id: string, userId: string, user_id: string},
841
+ "createOrganizationRole" => {organizationId: string, organization_id: string, role: string, roleName: string, role_name: string, permission: object, permissions: object},
842
+ "updateOrganizationRole" => {organizationId: string, organization_id: string, roleId: string, role_id: string, role: string, roleName: string, role_name: string, permission: object, permissions: object, data: object},
843
+ "deleteOrganizationRole" => {organizationId: string, organization_id: string, roleId: string, role_id: string, role: string, roleName: string, role_name: string}
844
+ }[operation_id]
845
+ end
846
+
784
847
  def organization_ref_schema(name)
785
848
  {
786
849
  type: "object",
@@ -969,7 +1032,7 @@ module BetterAuth
969
1032
  ctx.context.adapter.find_one(model: "organizationRole", where: [{field: "organizationId", value: organization_id}, {field: "role", value: role}])
970
1033
  end
971
1034
 
972
- def list_members_for(ctx, organization_id, query = {})
1035
+ def list_members_for(ctx, organization_id, query = {}, config = nil, user = nil)
973
1036
  where = [{field: "organizationId", value: organization_id}]
974
1037
  if query[:filter_field]
975
1038
  where << {field: query[:filter_field], value: query[:filter_value], operator: query[:filter_operator]}
@@ -977,19 +1040,48 @@ module BetterAuth
977
1040
  filter = normalize_hash(query[:filter])
978
1041
  where << {field: filter[:field], value: filter[:value], operator: filter[:operator]}
979
1042
  end
1043
+ limit = member_list_limit(ctx, organization_id, query, config, user)
980
1044
  members = ctx.context.adapter.find_many(
981
1045
  model: "member",
982
1046
  where: where,
983
- limit: query[:limit],
1047
+ limit: limit,
984
1048
  offset: query[:offset],
985
1049
  sort_by: query[:sort_by] ? {field: query[:sort_by], direction: query[:sort_direction] || query[:sort_order] || "asc"} : nil
986
1050
  )
1051
+ users_by_id = member_users_by_id(ctx, members)
987
1052
  {
988
- members: members.map { |entry| member_wire(ctx, entry) },
1053
+ members: members.map { |entry| member_wire(ctx, entry, users_by_id: users_by_id) },
989
1054
  total: ctx.context.adapter.count(model: "member", where: where)
990
1055
  }
991
1056
  end
992
1057
 
1058
+ def member_list_limit(ctx, organization_id, query, config, user)
1059
+ configured = config && config[:membership_limit]
1060
+ configured = 100 if configured.nil?
1061
+ default = numeric_member_limit(configured)
1062
+ default = 100 unless default.positive?
1063
+ requested = query[:limit].to_i if query.key?(:limit) && !query[:limit].to_s.empty?
1064
+ return default unless requested&.positive?
1065
+
1066
+ [requested, default].min
1067
+ end
1068
+
1069
+ def numeric_member_limit(value)
1070
+ return value.to_i if value.is_a?(Numeric)
1071
+ return value.to_i if value.to_s.match?(/\A\d+\z/)
1072
+
1073
+ 100
1074
+ end
1075
+
1076
+ def member_users_by_id(ctx, members)
1077
+ user_ids = members.map { |member| member["userId"] }.compact.uniq
1078
+ return {} if user_ids.empty?
1079
+
1080
+ ctx.context.adapter.find_many(model: "user", where: [{field: "id", operator: "in", value: user_ids}]).each_with_object({}) do |user, result|
1081
+ result[user["id"]] = user
1082
+ end
1083
+ end
1084
+
993
1085
  def ensure_team_member_capacity!(ctx, config, team_ids)
994
1086
  max_members = config.dig(:teams, :maximum_members_per_team)
995
1087
  return unless max_members && team_ids.any?
@@ -1002,9 +1094,9 @@ module BetterAuth
1002
1094
  end
1003
1095
  end
1004
1096
 
1005
- def member_wire(ctx, member)
1097
+ def member_wire(ctx, member, users_by_id: nil)
1006
1098
  data = Schema.parse_output(ctx.context.options, "member", member)
1007
- user = ctx.context.internal_adapter.find_user_by_id(member["userId"])
1099
+ user = users_by_id ? users_by_id[member["userId"]] : ctx.context.internal_adapter.find_user_by_id(member["userId"])
1008
1100
  data["user"] = user.slice("id", "name", "email", "image") if user
1009
1101
  data
1010
1102
  end
@@ -1034,8 +1126,11 @@ module BetterAuth
1034
1126
  def ensure_not_last_owner!(ctx, member)
1035
1127
  return unless member["role"].to_s.split(",").include?("owner")
1036
1128
 
1037
- owners = ctx.context.adapter.find_many(model: "member", where: [{field: "organizationId", value: member["organizationId"]}]).select { |entry| entry["role"].to_s.split(",").include?("owner") }
1038
- raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owners.length <= 1
1129
+ owner_count = 0
1130
+ organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "organizationId", value: member["organizationId"]}]) do |entry|
1131
+ owner_count += 1 if entry["role"].to_s.split(",").include?("owner")
1132
+ end
1133
+ raise APIError.new("BAD_REQUEST", message: ORGANIZATION_ERROR_CODES.fetch("YOU_CANNOT_LEAVE_THE_ORGANIZATION_AS_THE_ONLY_OWNER")) if owner_count <= 1
1039
1134
  end
1040
1135
 
1041
1136
  def create_default_team(ctx, config, organization, session)
@@ -1053,8 +1148,24 @@ module BetterAuth
1053
1148
  end
1054
1149
 
1055
1150
  def organization_created_count(ctx, user_id)
1056
- members = ctx.context.adapter.find_many(model: "member", where: [{field: "userId", value: user_id}])
1057
- members.count { |member| member["role"].to_s.split(",").include?("owner") }
1151
+ count = 0
1152
+ organization_each_adapter_record(ctx.context.adapter, "member", where: [{field: "userId", value: user_id}]) do |member|
1153
+ count += 1 if member["role"].to_s.split(",").include?("owner")
1154
+ end
1155
+ count
1156
+ end
1157
+
1158
+ def organization_each_adapter_record(adapter, model, where:, page_size: 100)
1159
+ offset = 0
1160
+ loop do
1161
+ records = adapter.find_many(model: model, where: where, limit: page_size, offset: offset)
1162
+ break if records.empty?
1163
+
1164
+ records.each { |record| yield record }
1165
+ break if records.length < page_size
1166
+
1167
+ offset += records.length
1168
+ end
1058
1169
  end
1059
1170
 
1060
1171
  def run_org_hook(config, key, data, ctx)
@@ -55,7 +55,7 @@ module BetterAuth
55
55
  rate_limit: [
56
56
  {
57
57
  path_matcher: ->(path) { path.start_with?("/phone-number") },
58
- window: 60_000,
58
+ window: 60,
59
59
  max: 10
60
60
  }
61
61
  ],
@@ -300,6 +300,7 @@ module BetterAuth
300
300
  openapi: {
301
301
  operationId: operation_id,
302
302
  description: description,
303
+ requestBody: two_factor_request_body(operation_id),
303
304
  responses: {
304
305
  "200" => OpenAPI.json_response("Success", response_schema)
305
306
  }
@@ -307,6 +308,26 @@ module BetterAuth
307
308
  }
308
309
  end
309
310
 
311
+ def two_factor_request_body(operation_id)
312
+ schema = case operation_id
313
+ when "enableTwoFactor"
314
+ OpenAPI.object_schema({password: {type: "string"}, issuer: {type: "string"}})
315
+ when "disableTwoFactor", "getTOTPURI", "generateBackupCodes"
316
+ OpenAPI.object_schema({password: {type: "string"}})
317
+ when "generateTOTP"
318
+ OpenAPI.object_schema({secret: {type: "string"}}, required: ["secret"])
319
+ when "verifyTOTP", "verifyTwoFactorOTP"
320
+ OpenAPI.object_schema({code: {type: "string"}, trustDevice: {type: "boolean"}, trust_device: {type: "boolean"}}, required: ["code"])
321
+ when "verifyBackupCode"
322
+ OpenAPI.object_schema({code: {type: "string"}, disableSession: {type: "boolean"}, disable_session: {type: "boolean"}, trustDevice: {type: "boolean"}, trust_device: {type: "boolean"}}, required: ["code"])
323
+ when "sendTwoFactorOTP"
324
+ OpenAPI.empty_request_body.dig(:content, "application/json", :schema)
325
+ else
326
+ {type: "object", properties: {}}
327
+ end
328
+ OpenAPI.json_request_body(schema)
329
+ end
330
+
310
331
  def two_factor_enable_response_schema
311
332
  OpenAPI.object_schema(
312
333
  {
@@ -121,9 +121,14 @@ module BetterAuth
121
121
  end
122
122
 
123
123
  def default_special_rule(path)
124
- return unless path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
124
+ return {window: 10, max: 3} if path.start_with?("/sign-in", "/sign-up", "/change-password", "/change-email")
125
+ return {window: 60, max: 3} if path == "/request-password-reset" ||
126
+ path == "/send-verification-email" ||
127
+ path.start_with?("/forget-password") ||
128
+ path == "/email-otp/send-verification-otp" ||
129
+ path == "/email-otp/request-password-reset"
125
130
 
126
- {window: 10, max: 3}
131
+ nil
127
132
  end
128
133
 
129
134
  def matching_custom_rule(config, path)
@@ -118,6 +118,8 @@ module BetterAuth
118
118
  ) do |ctx|
119
119
  session = current_session(ctx, allow_nil: true)
120
120
  body = normalize_hash(ctx.body)
121
+ raise APIError.new("UNAUTHORIZED") if ctx.request && !session
122
+
121
123
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
122
124
  raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
123
125
 
@@ -174,6 +176,8 @@ module BetterAuth
174
176
  ) do |ctx|
175
177
  session = current_session(ctx, allow_nil: true)
176
178
  body = normalize_hash(ctx.body)
179
+ raise APIError.new("UNAUTHORIZED") if ctx.request && !session
180
+
177
181
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
178
182
  raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
179
183
 
@@ -184,7 +184,11 @@ module BetterAuth
184
184
 
185
185
  def self.set_verified_session_cookie(ctx, user)
186
186
  session = current_session(ctx, allow_nil: true)
187
- session_data = session ? session[:session] : ctx.context.internal_adapter.create_session(user["id"])
187
+ session_data = if session && session[:user]["id"] == user["id"]
188
+ session[:session]
189
+ else
190
+ ctx.context.internal_adapter.create_session(user["id"])
191
+ end
188
192
  Cookies.set_session_cookie(ctx, {session: session_data, user: user})
189
193
  end
190
194
 
@@ -179,6 +179,7 @@ module BetterAuth
179
179
  path: "/verify-password",
180
180
  method: "POST",
181
181
  metadata: {
182
+ scope: "server",
182
183
  openapi: {
183
184
  operationId: "verifyPassword",
184
185
  description: "Verify the current user's password",
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "uri"
4
4
  require "json"
5
+ require "net/http"
5
6
  require "securerandom"
6
7
 
7
8
  module BetterAuth
@@ -92,6 +93,7 @@ module BetterAuth
92
93
  ctx.context.secret,
93
94
  expires_in: 600
94
95
  )
96
+ store_oauth_state_cookie(ctx, state)
95
97
  url = call_provider(provider, :create_authorization_url, {
96
98
  state: state,
97
99
  codeVerifier: code_verifier,
@@ -148,6 +150,7 @@ module BetterAuth
148
150
  raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
149
151
  raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
150
152
  raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
153
+ raise ctx.redirect(oauth_error_url(error_url, "state_mismatch")) unless valid_oauth_state_cookie?(ctx, state)
151
154
  raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
152
155
 
153
156
  tokens = call_provider(provider, :validate_authorization_code, {
@@ -161,7 +164,11 @@ module BetterAuth
161
164
 
162
165
  token_data = token_hash(tokens)
163
166
  token_data["user"] = parse_json_hash(data["user"]) if data["user"]
164
- user_info = call_provider(provider, :get_user_info, token_data)
167
+ user_info = begin
168
+ call_provider(provider, :get_user_info, token_data)
169
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
170
+ nil
171
+ end
165
172
  user = user_info[:user] || user_info["user"] if user_info
166
173
  raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
167
174
  raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
@@ -269,6 +276,7 @@ module BetterAuth
269
276
  }
270
277
  }.merge(safe_additional_state(body))
271
278
  state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
279
+ store_oauth_state_cookie(ctx, state)
272
280
  url = call_provider(provider, :create_authorization_url, {
273
281
  state: state,
274
282
  codeVerifier: code_verifier,
@@ -285,6 +293,10 @@ module BetterAuth
285
293
 
286
294
  def self.social_user_from_id_token!(ctx, provider, id_token)
287
295
  token = fetch_value(id_token, "token").to_s
296
+ unless provider_callable(provider, :verify_id_token)
297
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["ID_TOKEN_NOT_SUPPORTED"])
298
+ end
299
+
288
300
  valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
289
301
  raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
290
302
 
@@ -360,6 +372,22 @@ module BetterAuth
360
372
  {session: session, user: user, new_user: new_user}
361
373
  end
362
374
 
375
+ def self.store_oauth_state_cookie(ctx, state)
376
+ return unless ctx.request
377
+
378
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
379
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
380
+ end
381
+
382
+ def self.valid_oauth_state_cookie?(ctx, state)
383
+ return true unless ctx.request
384
+
385
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
386
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
387
+ Cookies.expire_cookie(ctx, cookie)
388
+ stored == state
389
+ end
390
+
363
391
  def self.oauth_error_url(base_url, error, description = nil)
364
392
  uri = URI.parse(base_url.to_s)
365
393
  query = URI.decode_www_form(uri.query.to_s)
@@ -80,7 +80,9 @@ module BetterAuth
80
80
  current_password = body["currentPassword"] || body["current_password"]
81
81
  validate_password_length!(new_password, ctx.context.options.email_and_password)
82
82
  account = credential_account(ctx, session[:user]["id"])
83
- unless account && account["password"] && verify_password_value(ctx, current_password.to_s, account["password"])
83
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
84
+
85
+ unless verify_password_value(ctx, current_password.to_s, account["password"])
84
86
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
85
87
  end
86
88
 
@@ -176,7 +178,9 @@ module BetterAuth
176
178
  sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification)
177
179
  if body["password"]
178
180
  account = credential_account(ctx, session[:user]["id"])
179
- unless account && account["password"] && verify_password_value(ctx, body["password"], account["password"])
181
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["CREDENTIAL_ACCOUNT_NOT_FOUND"]) unless account && account["password"]
182
+
183
+ unless verify_password_value(ctx, body["password"], account["password"])
180
184
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
181
185
  end
182
186
  end