better_auth 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -11,17 +11,52 @@ module BetterAuth
11
11
  Endpoint.new(
12
12
  path: "/sign-up/email",
13
13
  method: "POST",
14
+ body_schema: request_body_schema(
15
+ required_strings: %w[name email],
16
+ required_nonempty_strings: %w[password]
17
+ ),
14
18
  metadata: {
15
19
  allowed_media_types: [
16
20
  "application/x-www-form-urlencoded",
17
21
  "application/json"
18
- ]
22
+ ],
23
+ openapi: {
24
+ operationId: "signUpWithEmailAndPassword",
25
+ description: "Sign up a user using email and password",
26
+ requestBody: OpenAPI.json_request_body(
27
+ OpenAPI.object_schema(
28
+ {
29
+ name: {type: "string", description: "The name of the user"},
30
+ email: {type: "string", description: "The email of the user"},
31
+ password: {type: "string", description: "The password of the user"},
32
+ image: {type: "string", description: "The profile image URL of the user"},
33
+ callbackURL: {type: "string", description: "The URL to use for email verification callback"},
34
+ rememberMe: {type: "boolean", description: "If this is false, the session will not be remembered. Default is `true`."}
35
+ },
36
+ required: ["name", "email", "password"]
37
+ ),
38
+ required: false
39
+ ),
40
+ responses: {
41
+ "200" => OpenAPI.json_response(
42
+ "Successfully created user",
43
+ OpenAPI.object_schema(
44
+ {
45
+ token: {type: "string", nullable: true, description: "Authentication token for the session"},
46
+ user: {type: "object", "$ref": "#/components/schemas/User"}
47
+ },
48
+ required: ["user"]
49
+ )
50
+ ),
51
+ "422" => OpenAPI.error_response("Unprocessable Entity. User already exists or failed to create user.")
52
+ }
53
+ }
19
54
  }
20
55
  ) do |ctx|
21
56
  options = ctx.context.options
22
57
  email_config = options.email_and_password
23
58
  if email_config[:enabled] != true || email_config[:disable_sign_up]
24
- raise APIError.new("BAD_REQUEST", message: "Email and password sign up is not enabled")
59
+ raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_SIGN_UP_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_SIGN_UP_DISABLED"])
25
60
  end
26
61
 
27
62
  body = normalize_hash(ctx.body)
@@ -32,6 +67,7 @@ module BetterAuth
32
67
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
33
68
  remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
34
69
 
70
+ validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
35
71
  validate_sign_up_input!(email, password, email_config)
36
72
 
37
73
  ctx.context.adapter.transaction do
@@ -101,6 +137,13 @@ module BetterAuth
101
137
  end
102
138
  end
103
139
 
140
+ def self.validate_auth_callback_url!(context, value, label)
141
+ return if value.nil? || value.to_s.empty?
142
+ return if context.trusted_origin?(value.to_s, allow_relative_paths: true)
143
+
144
+ raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
145
+ end
146
+
104
147
  def self.create_sign_up_user(ctx, body, email, name, image)
105
148
  reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
106
149
  additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
@@ -110,7 +153,8 @@ module BetterAuth
110
153
  "name" => name,
111
154
  "image" => image,
112
155
  "emailVerified" => false
113
- )
156
+ ),
157
+ context: ctx
114
158
  )
115
159
  rescue APIError
116
160
  raise
@@ -143,18 +187,40 @@ module BetterAuth
143
187
  "updatedAt" => now
144
188
  }
145
189
  reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
146
- additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
190
+ additional = synthetic_additional_user_fields(ctx, body.except(*reserved))
147
191
  custom = ctx.context.options.email_and_password[:custom_synthetic_user]
148
192
  return core_fields.merge(additional) unless custom.respond_to?(:call)
149
193
 
150
194
  value = {
151
195
  core_fields: core_fields.except("id"),
196
+ coreFields: core_fields.except("id"),
152
197
  additional_fields: additional,
198
+ additionalFields: additional,
153
199
  id: core_fields["id"]
154
200
  }
155
201
  stringify_synthetic_user(custom.call(value))
156
202
  end
157
203
 
204
+ def self.synthetic_additional_user_fields(ctx, data)
205
+ additional = parse_declared_input(ctx, "user", data, allowed_base: [])
206
+ configured = ctx.context.options.user[:additional_fields] || {}
207
+ configured.each do |field, attributes|
208
+ storage_field = Schema.storage_key(field)
209
+ next if additional.key?(storage_field)
210
+
211
+ field_attributes = normalize_hash(attributes || {})
212
+ next unless field_attributes.key?("defaultValue") || field_attributes.key?("default_value")
213
+
214
+ default = field_attributes.key?("defaultValue") ? field_attributes["defaultValue"] : field_attributes["default_value"]
215
+ additional[storage_field] = resolve_default(default)
216
+ end
217
+ additional
218
+ end
219
+
220
+ def self.resolve_default(value)
221
+ value.respond_to?(:call) ? value.call : value
222
+ end
223
+
158
224
  def self.stringify_synthetic_user(value)
159
225
  return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash)
160
226
 
@@ -7,7 +7,49 @@ require "securerandom"
7
7
  module BetterAuth
8
8
  module Routes
9
9
  def self.sign_in_social
10
- Endpoint.new(path: "/sign-in/social", method: "POST") do |ctx|
10
+ Endpoint.new(
11
+ path: "/sign-in/social",
12
+ method: "POST",
13
+ metadata: {
14
+ openapi: {
15
+ description: "Sign in with a social provider",
16
+ operationId: "socialSignIn",
17
+ requestBody: OpenAPI.json_request_body(
18
+ OpenAPI.object_schema(
19
+ {
20
+ provider: {type: "string"},
21
+ callbackURL: {type: ["string", "null"], description: "Callback URL to redirect to after the user has signed in"},
22
+ errorCallbackURL: {type: ["string", "null"], description: "Callback URL to redirect to if an error happens"},
23
+ newUserCallbackURL: {type: ["string", "null"]},
24
+ disableRedirect: {type: ["boolean", "null"], description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself"},
25
+ requestSignUp: {type: ["boolean", "null"], description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider"},
26
+ loginHint: {type: ["string", "null"], description: "The login hint to use for the authorization code request"},
27
+ additionalData: {type: ["string", "null"]},
28
+ scopes: {type: ["array", "null"], description: "Array of scopes to request from the provider. This will override the default scopes passed."},
29
+ idToken: {
30
+ type: ["object", "null"],
31
+ properties: {
32
+ token: {type: "string", description: "ID token from the provider"},
33
+ accessToken: {type: ["string", "null"], description: "Access token from the provider"},
34
+ refreshToken: {type: ["string", "null"], description: "Refresh token from the provider"},
35
+ expiresAt: {type: ["number", "null"], description: "Expiry date of the token"},
36
+ nonce: {type: ["string", "null"], description: "Nonce used to generate the token"}
37
+ },
38
+ required: ["token"]
39
+ }
40
+ },
41
+ required: ["provider"]
42
+ )
43
+ ),
44
+ responses: {
45
+ "200" => OpenAPI.json_response(
46
+ "Success - Returns either session details or redirect URL",
47
+ OpenAPI.session_response_schema(description: "Session response when idToken is provided")
48
+ )
49
+ }
50
+ }
51
+ }
52
+ ) do |ctx|
11
53
  body = normalize_hash(ctx.body)
12
54
  provider_id = body["provider"].to_s
13
55
  provider = social_provider(ctx.context, provider_id)
@@ -66,21 +108,38 @@ module BetterAuth
66
108
 
67
109
  def self.callback_oauth
68
110
  Endpoint.new(
69
- path: "/callback/:providerId",
111
+ path: "/callback/:id",
70
112
  method: ["GET", "POST"],
71
- metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
113
+ metadata: {
114
+ allowed_media_types: ["application/x-www-form-urlencoded", "application/json"],
115
+ openapi: {
116
+ operationId: "callbackOAuth",
117
+ description: "Handle an OAuth provider callback",
118
+ parameters: [
119
+ {
120
+ name: "id",
121
+ in: "path",
122
+ required: true,
123
+ schema: {type: "string"}
124
+ }
125
+ ],
126
+ responses: {
127
+ "302" => {description: "Redirects to the configured callback URL"}
128
+ }
129
+ }
130
+ }
72
131
  ) do |ctx|
132
+ provider_id = (fetch_value(ctx.params, "id") || fetch_value(ctx.params, "providerId")).to_s
73
133
  if ctx.method == "POST"
74
134
  merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
75
135
  query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? })
76
- target = "#{ctx.context.base_url}/callback/#{fetch_value(ctx.params, "providerId")}"
136
+ target = "#{ctx.context.base_url}/callback/#{provider_id}"
77
137
  target = "#{target}?#{query}" unless query.empty?
78
138
  raise ctx.redirect(target)
79
139
  end
80
140
 
81
141
  source = ctx.query
82
142
  data = normalize_hash(source)
83
- provider_id = fetch_value(ctx.params, "providerId").to_s
84
143
  provider = social_provider(ctx.context, provider_id)
85
144
  state = data["state"].to_s
86
145
  state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
@@ -132,7 +191,42 @@ module BetterAuth
132
191
  end
133
192
 
134
193
  def self.link_social
135
- Endpoint.new(path: "/link-social", method: "POST") do |ctx|
194
+ Endpoint.new(
195
+ path: "/link-social",
196
+ method: "POST",
197
+ metadata: {
198
+ openapi: {
199
+ operationId: "linkSocialAccount",
200
+ description: "Link a social account to the current user",
201
+ requestBody: OpenAPI.json_request_body(
202
+ OpenAPI.object_schema(
203
+ {
204
+ provider: {type: "string"},
205
+ callbackURL: {type: ["string", "null"]},
206
+ errorCallbackURL: {type: ["string", "null"]},
207
+ disableRedirect: {type: ["boolean", "null"]},
208
+ scopes: {type: ["array", "null"], items: {type: "string"}},
209
+ idToken: {type: ["object", "null"]}
210
+ },
211
+ required: ["provider"]
212
+ )
213
+ ),
214
+ responses: {
215
+ "200" => OpenAPI.json_response(
216
+ "Social account link started or completed",
217
+ OpenAPI.object_schema(
218
+ {
219
+ url: {type: "string"},
220
+ redirect: {type: "boolean"},
221
+ status: {type: ["boolean", "null"]}
222
+ },
223
+ required: ["url", "redirect"]
224
+ )
225
+ )
226
+ }
227
+ }
228
+ }
229
+ ) do |ctx|
136
230
  session = current_session(ctx)
137
231
  body = normalize_hash(ctx.body)
138
232
  provider_id = body["provider"].to_s
@@ -228,6 +322,12 @@ module BetterAuth
228
322
 
229
323
  if existing && existing[:linked_account]
230
324
  user = existing[:user]
325
+ if ctx.context.options.account[:update_account_on_sign_in] != false
326
+ update_data = account_storage_fields(account_info)
327
+ ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data) unless update_data.empty?
328
+ end
329
+ verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
330
+ user = verified_user if verified_user
231
331
  new_user = false
232
332
  elsif existing
233
333
  unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
@@ -235,6 +335,8 @@ module BetterAuth
235
335
  end
236
336
  user = existing[:user]
237
337
  ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
338
+ verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
339
+ user = verified_user if verified_user
238
340
  new_user = false
239
341
  else
240
342
  return {error: "signup disabled"} if disable_sign_up
@@ -246,11 +348,13 @@ module BetterAuth
246
348
  image: fetch_value(user_info, "image"),
247
349
  emailVerified: !!fetch_value(user_info, "emailVerified")
248
350
  },
249
- account_info.merge("providerId" => provider_id, "accountId" => account_id)
351
+ account_info.merge("providerId" => provider_id, "accountId" => account_id),
352
+ context: ctx
250
353
  )
251
354
  user = created[:user]
252
355
  new_user = true
253
356
  end
357
+ user = override_social_user_info(ctx, user, user_info) if existing && provider_override_user_info_on_sign_in?(provider_id, ctx.context)
254
358
 
255
359
  session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
256
360
  {session: session, user: user, new_user: new_user}
@@ -318,6 +422,27 @@ module BetterAuth
318
422
  {status: true}
319
423
  end
320
424
 
425
+ def self.provider_override_user_info_on_sign_in?(provider_id, context)
426
+ provider = social_provider(context, provider_id)
427
+ !!(fetch_value(provider, "overrideUserInfoOnSignIn") || fetch_value(fetch_value(provider, "options") || {}, "overrideUserInfoOnSignIn"))
428
+ end
429
+
430
+ def self.override_social_user_info(ctx, user, user_info)
431
+ email = fetch_value(user_info, "email").to_s.downcase
432
+ email_verified = if email == user["email"].to_s.downcase
433
+ !!(user["emailVerified"] || fetch_value(user_info, "emailVerified"))
434
+ else
435
+ !!fetch_value(user_info, "emailVerified")
436
+ end
437
+ update = {
438
+ "email" => email,
439
+ "name" => fetch_value(user_info, "name").to_s,
440
+ "image" => fetch_value(user_info, "image"),
441
+ "emailVerified" => email_verified
442
+ }.reject { |_key, value| value.nil? }
443
+ ctx.context.internal_adapter.update_user(user["id"], update) || user
444
+ end
445
+
321
446
  def self.safe_additional_state(body)
322
447
  additional = body["additionalData"] || body["additional_data"]
323
448
  return {} unless additional.is_a?(Hash)
@@ -1,12 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+ require "uri"
5
+
3
6
  module BetterAuth
4
7
  module Routes
5
8
  def self.update_user
6
- Endpoint.new(path: "/update-user", method: "POST") do |ctx|
9
+ Endpoint.new(
10
+ path: "/update-user",
11
+ method: "POST",
12
+ metadata: {
13
+ openapi: {
14
+ operationId: "updateUser",
15
+ description: "Update the current user's profile",
16
+ requestBody: OpenAPI.json_request_body(
17
+ OpenAPI.object_schema(
18
+ {
19
+ name: {type: ["string", "null"], description: "The user's name"},
20
+ image: {type: ["string", "null"], description: "The user's profile image URL"}
21
+ }
22
+ )
23
+ ),
24
+ responses: {
25
+ "200" => OpenAPI.json_response("User updated", OpenAPI.status_response_schema)
26
+ }
27
+ }
28
+ }
29
+ ) do |ctx|
7
30
  session = current_session(ctx)
31
+ raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash)
32
+
8
33
  body = normalize_hash(ctx.body)
9
- raise APIError.new("BAD_REQUEST", message: "Body must be an object") unless body.is_a?(Hash)
10
34
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_CAN_NOT_BE_UPDATED"]) if body.key?("email")
11
35
  update = parse_declared_input(ctx, "user", body, allowed_base: ["name", "image"])
12
36
  raise APIError.new("BAD_REQUEST", message: "No fields to update") if update.empty?
@@ -18,7 +42,38 @@ module BetterAuth
18
42
  end
19
43
 
20
44
  def self.change_password
21
- Endpoint.new(path: "/change-password", method: "POST") do |ctx|
45
+ Endpoint.new(
46
+ path: "/change-password",
47
+ method: "POST",
48
+ metadata: {
49
+ openapi: {
50
+ description: "Change the password of the user",
51
+ operationId: "changePassword",
52
+ requestBody: OpenAPI.json_request_body(
53
+ OpenAPI.object_schema(
54
+ {
55
+ newPassword: {type: "string", description: "The new password to set"},
56
+ currentPassword: {type: "string", description: "The current password is required"},
57
+ revokeOtherSessions: {type: ["boolean", "null"], description: "Must be a boolean value"}
58
+ },
59
+ required: ["newPassword", "currentPassword"]
60
+ )
61
+ ),
62
+ responses: {
63
+ "200" => OpenAPI.json_response(
64
+ "Password successfully changed",
65
+ OpenAPI.object_schema(
66
+ {
67
+ token: {type: "string", nullable: true, description: "New session token if other sessions were revoked"},
68
+ user: OpenAPI.user_response_schema
69
+ },
70
+ required: ["user"]
71
+ )
72
+ )
73
+ }
74
+ }
75
+ }
76
+ ) do |ctx|
22
77
  session = current_session(ctx, sensitive: true)
23
78
  body = normalize_hash(ctx.body)
24
79
  new_password = body["newPassword"] || body["new_password"]
@@ -42,13 +97,33 @@ module BetterAuth
42
97
  end
43
98
 
44
99
  def self.set_password
45
- Endpoint.new(path: "/set-password", method: "POST") do |ctx|
100
+ Endpoint.new(
101
+ path: "/set-password",
102
+ method: "POST",
103
+ metadata: {
104
+ openapi: {
105
+ operationId: "setPassword",
106
+ description: "Set a password for the current user",
107
+ requestBody: OpenAPI.json_request_body(
108
+ OpenAPI.object_schema(
109
+ {
110
+ newPassword: {type: "string", description: "The password to set"}
111
+ },
112
+ required: ["newPassword"]
113
+ )
114
+ ),
115
+ responses: {
116
+ "200" => OpenAPI.json_response("Password set", OpenAPI.status_response_schema)
117
+ }
118
+ }
119
+ }
120
+ ) do |ctx|
46
121
  session = current_session(ctx, sensitive: true)
47
122
  body = normalize_hash(ctx.body)
48
123
  new_password = body["newPassword"] || body["new_password"]
49
124
  validate_password_length!(new_password, ctx.context.options.email_and_password)
50
125
  account = credential_account(ctx, session[:user]["id"])
51
- raise APIError.new("BAD_REQUEST", message: "user already has a password") if account && account["password"]
126
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_ALREADY_SET"]) if account && account["password"]
52
127
 
53
128
  ctx.context.internal_adapter.link_account(
54
129
  userId: session[:user]["id"],
@@ -61,7 +136,37 @@ module BetterAuth
61
136
  end
62
137
 
63
138
  def self.delete_user
64
- Endpoint.new(path: "/delete-user", method: "POST") do |ctx|
139
+ Endpoint.new(
140
+ path: "/delete-user",
141
+ method: "POST",
142
+ metadata: {
143
+ openapi: {
144
+ operationId: "deleteUser",
145
+ description: "Delete the current user",
146
+ requestBody: OpenAPI.json_request_body(
147
+ OpenAPI.object_schema(
148
+ {
149
+ password: {type: ["string", "null"], description: "The user's password"},
150
+ token: {type: ["string", "null"], description: "Delete account verification token"},
151
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after deletion"}
152
+ }
153
+ )
154
+ ),
155
+ responses: {
156
+ "200" => OpenAPI.json_response(
157
+ "User deleted or verification email sent",
158
+ OpenAPI.object_schema(
159
+ {
160
+ success: {type: "boolean"},
161
+ message: {type: "string"}
162
+ },
163
+ required: ["success", "message"]
164
+ )
165
+ )
166
+ }
167
+ }
168
+ }
169
+ ) do |ctx|
65
170
  enabled = ctx.context.options.user.dig(:delete_user, :enabled)
66
171
  raise APIError.new("NOT_FOUND") unless enabled
67
172
 
@@ -79,12 +184,15 @@ module BetterAuth
79
184
  delete_user_by_token!(ctx, session, body["token"])
80
185
  elsif sender
81
186
  token = SecureRandom.hex(16)
187
+ expires_in = ctx.context.options.user.dig(:delete_user, :delete_token_expires_in) || 3600
188
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/"
189
+ url = "#{ctx.context.base_url}/delete-user/callback?token=#{URI.encode_www_form_component(token)}&callbackURL=#{URI.encode_www_form_component(callback_url)}"
82
190
  ctx.context.internal_adapter.create_verification_value(
83
191
  identifier: "delete-account-#{token}",
84
192
  value: session[:user]["id"],
85
- expiresAt: Time.now + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
193
+ expiresAt: Time.now + expires_in.to_i
86
194
  )
87
- sender.call({user: session[:user], token: token}, ctx.request)
195
+ sender.call({user: session[:user], url: url, token: token}, ctx.request)
88
196
  next ctx.json({success: true, message: "Verification email sent"})
89
197
  elsif !body["password"]
90
198
  require_fresh_session!(ctx, session)
@@ -96,14 +204,49 @@ module BetterAuth
96
204
  end
97
205
 
98
206
  def self.delete_user_callback
99
- Endpoint.new(path: "/delete-user/callback", method: "GET") do |ctx|
207
+ Endpoint.new(
208
+ path: "/delete-user/callback",
209
+ method: "GET",
210
+ metadata: {
211
+ openapi: {
212
+ operationId: "deleteUserCallback",
213
+ description: "Delete the current user using a verification token",
214
+ parameters: [
215
+ {
216
+ name: "token",
217
+ in: "query",
218
+ required: true,
219
+ schema: {type: "string"}
220
+ },
221
+ {
222
+ name: "callbackURL",
223
+ in: "query",
224
+ required: false,
225
+ schema: {type: "string"}
226
+ }
227
+ ],
228
+ responses: {
229
+ "200" => OpenAPI.json_response(
230
+ "User deleted",
231
+ OpenAPI.object_schema(
232
+ {
233
+ success: {type: "boolean"},
234
+ message: {type: "string"}
235
+ },
236
+ required: ["success", "message"]
237
+ )
238
+ )
239
+ }
240
+ }
241
+ }
242
+ ) do |ctx|
100
243
  enabled = ctx.context.options.user.dig(:delete_user, :enabled)
101
244
  raise APIError.new("NOT_FOUND") unless enabled
102
245
  session = current_session(ctx)
103
246
  token = fetch_value(ctx.query, "token")
104
- delete_user_by_token!(ctx, session, token)
105
247
  callback_url = fetch_value(ctx.query, "callbackURL")
106
248
  validate_callback_url!(ctx.context, callback_url)
249
+ delete_user_by_token!(ctx, session, token)
107
250
  delete_current_user!(ctx, session)
108
251
  raise ctx.redirect(callback_url) if callback_url
109
252
 
@@ -112,7 +255,43 @@ module BetterAuth
112
255
  end
113
256
 
114
257
  def self.change_email
115
- Endpoint.new(path: "/change-email", method: "POST") do |ctx|
258
+ Endpoint.new(
259
+ path: "/change-email",
260
+ method: "POST",
261
+ metadata: {
262
+ openapi: {
263
+ operationId: "changeEmail",
264
+ requestBody: OpenAPI.json_request_body(
265
+ OpenAPI.object_schema(
266
+ {
267
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after email verification"},
268
+ newEmail: {type: "string", description: "The new email address to set must be a valid email address"}
269
+ },
270
+ required: ["newEmail"]
271
+ )
272
+ ),
273
+ responses: {
274
+ "200" => OpenAPI.json_response(
275
+ "Email change request processed successfully",
276
+ OpenAPI.object_schema(
277
+ {
278
+ message: {
279
+ type: "string",
280
+ nullable: true,
281
+ enum: ["Email updated", "Verification email sent"],
282
+ description: "Status message of the email change process"
283
+ },
284
+ status: {type: "boolean", description: "Indicates if the request was successful"},
285
+ user: {type: "object", "$ref": "#/components/schemas/User"}
286
+ },
287
+ required: ["status"]
288
+ )
289
+ ),
290
+ "422" => OpenAPI.error_response("Unprocessable Entity. Email already exists")
291
+ }
292
+ }
293
+ }
294
+ ) do |ctx|
116
295
  enabled = ctx.context.options.user.dig(:change_email, :enabled)
117
296
  raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
118
297
  session = current_session(ctx, sensitive: true)
@@ -120,23 +299,48 @@ module BetterAuth
120
299
  new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
121
300
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
122
301
  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)
302
+ sender = ctx.context.options.email_verification[:send_verification_email]
303
+ confirmation_sender = ctx.context.options.user.dig(:change_email, :send_change_email_confirmation)
304
+ can_update_without_verification = !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
305
+ can_send_confirmation = session[:user]["emailVerified"] && confirmation_sender.respond_to?(:call)
306
+ can_send_verification = sender.respond_to?(:call)
307
+ unless can_update_without_verification || can_send_confirmation || can_send_verification
308
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"])
309
+ end
124
310
 
125
- if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
311
+ existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
312
+ next ctx.json({status: true}) if existing_target
313
+
314
+ if can_update_without_verification
126
315
  updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
127
316
  Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
317
+ send_verification_email_payload(ctx, updated, body["callbackURL"] || body["callbackUrl"] || body["callback_url"]) if can_send_verification
128
318
  next ctx.json({status: true})
129
319
  end
130
320
 
131
- sender = ctx.context.options.email_verification[:send_verification_email]
132
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
321
+ if can_send_confirmation
322
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
323
+ token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-confirmation"})
324
+ url = email_verification_url(ctx, token, callback_url)
325
+ confirmation_sender.call({user: session[:user], new_email: new_email, url: url, token: token}, ctx.request)
326
+ next ctx.json({status: true})
327
+ end
133
328
 
134
- token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
135
- sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
329
+ send_change_email_verification(ctx, sender, session[:user], session[:user]["email"], new_email, body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
136
330
  ctx.json({status: true})
137
331
  end
138
332
  end
139
333
 
334
+ def self.send_change_email_verification(ctx, sender, user, current_email, new_email, callback_url)
335
+ token = create_email_verification_token(ctx, current_email, update_to: new_email, extra: {"requestType" => "change-email-verification"})
336
+ sender.call({user: user.merge("email" => new_email), url: email_verification_url(ctx, token, callback_url), token: token}, ctx.request)
337
+ end
338
+
339
+ def self.email_verification_url(ctx, token, callback_url)
340
+ callback = URI.encode_www_form_component(callback_url || "/")
341
+ "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
342
+ end
343
+
140
344
  def self.delete_user_by_token!(ctx, session, token)
141
345
  verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
142
346
  unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
@@ -148,7 +352,9 @@ module BetterAuth
148
352
  def self.delete_current_user!(ctx, session)
149
353
  config = ctx.context.options.user[:delete_user] || {}
150
354
  call_option(config[:before_delete], session[:user], ctx.request)
151
- ctx.context.internal_adapter.delete_user(session[:user]["id"])
355
+ deleted = ctx.context.internal_adapter.delete_user(session[:user]["id"])
356
+ raise APIError.new("BAD_REQUEST", message: "User delete aborted") if deleted == false
357
+
152
358
  ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
153
359
  Cookies.delete_session_cookie(ctx)
154
360
  call_option(config[:after_delete], session[:user], ctx.request)
@@ -158,8 +364,10 @@ module BetterAuth
158
364
  fresh_age = ctx.context.session_config[:fresh_age].to_i
159
365
  return if fresh_age <= 0
160
366
 
161
- updated_at = Session.normalize_time(session[:session]["updatedAt"] || session[:session]["updated_at"] || session[:session]["createdAt"] || session[:session]["created_at"])
162
- raise APIError.new("UNAUTHORIZED") unless updated_at && updated_at + fresh_age > Time.now
367
+ created_at = Session.normalize_time(session[:session]["createdAt"] || session[:session]["created_at"])
368
+ return if created_at && created_at + fresh_age > Time.now
369
+
370
+ raise APIError.new("BAD_REQUEST", code: "SESSION_EXPIRED", message: BASE_ERROR_CODES["SESSION_EXPIRED"])
163
371
  end
164
372
 
165
373
  def self.parse_declared_input(ctx, model, data, allowed_base: [])