better_auth 0.4.0 → 0.6.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +5 -5
  5. data/lib/better_auth/adapters/sql.rb +96 -18
  6. data/lib/better_auth/api.rb +113 -13
  7. data/lib/better_auth/configuration.rb +97 -7
  8. data/lib/better_auth/context.rb +165 -12
  9. data/lib/better_auth/cookies.rb +6 -4
  10. data/lib/better_auth/core.rb +2 -0
  11. data/lib/better_auth/crypto/jwe.rb +27 -5
  12. data/lib/better_auth/crypto.rb +32 -0
  13. data/lib/better_auth/database_hooks.rb +5 -5
  14. data/lib/better_auth/endpoint.rb +87 -3
  15. data/lib/better_auth/error.rb +8 -1
  16. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  17. data/lib/better_auth/plugins/admin.rb +344 -16
  18. data/lib/better_auth/plugins/anonymous.rb +37 -3
  19. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  20. data/lib/better_auth/plugins/dub.rb +148 -0
  21. data/lib/better_auth/plugins/email_otp.rb +246 -15
  22. data/lib/better_auth/plugins/expo.rb +17 -1
  23. data/lib/better_auth/plugins/generic_oauth.rb +53 -7
  24. data/lib/better_auth/plugins/jwt.rb +37 -4
  25. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  26. data/lib/better_auth/plugins/magic_link.rb +66 -3
  27. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  28. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  29. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  30. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  31. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  32. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  33. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  34. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  35. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  36. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  37. data/lib/better_auth/plugins/mcp.rb +111 -263
  38. data/lib/better_auth/plugins/multi_session.rb +61 -3
  39. data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
  40. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  41. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  42. data/lib/better_auth/plugins/one_tap.rb +7 -2
  43. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  44. data/lib/better_auth/plugins/open_api.rb +163 -318
  45. data/lib/better_auth/plugins/organization.rb +135 -36
  46. data/lib/better_auth/plugins/phone_number.rb +141 -6
  47. data/lib/better_auth/plugins/siwe.rb +69 -3
  48. data/lib/better_auth/plugins/two_factor.rb +65 -23
  49. data/lib/better_auth/plugins/username.rb +57 -2
  50. data/lib/better_auth/rate_limiter.rb +20 -0
  51. data/lib/better_auth/response.rb +42 -0
  52. data/lib/better_auth/router.rb +7 -1
  53. data/lib/better_auth/routes/account.rb +204 -38
  54. data/lib/better_auth/routes/email_verification.rb +98 -14
  55. data/lib/better_auth/routes/password.rb +125 -8
  56. data/lib/better_auth/routes/session.rb +128 -13
  57. data/lib/better_auth/routes/sign_in.rb +24 -2
  58. data/lib/better_auth/routes/sign_out.rb +13 -1
  59. data/lib/better_auth/routes/sign_up.rb +62 -4
  60. data/lib/better_auth/routes/social.rb +102 -7
  61. data/lib/better_auth/routes/user.rb +222 -20
  62. data/lib/better_auth/routes/validation.rb +50 -0
  63. data/lib/better_auth/secret_config.rb +115 -0
  64. data/lib/better_auth/session.rb +1 -1
  65. data/lib/better_auth/url_helpers.rb +12 -1
  66. data/lib/better_auth/version.rb +1 -1
  67. data/lib/better_auth.rb +4 -0
  68. metadata +15 -1
@@ -3,7 +3,22 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.list_accounts
6
- Endpoint.new(path: "/list-accounts", method: "GET") do |ctx|
6
+ Endpoint.new(
7
+ path: "/list-accounts",
8
+ method: "GET",
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "listUserAccounts",
12
+ description: "List linked accounts for the current user",
13
+ responses: {
14
+ "200" => OpenAPI.json_response(
15
+ "Linked accounts",
16
+ {type: "array", items: {type: "object", "$ref": "#/components/schemas/Account"}}
17
+ )
18
+ }
19
+ }
20
+ }
21
+ ) do |ctx|
7
22
  session = current_session(ctx)
8
23
  accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account|
9
24
  parsed = Schema.parse_output(ctx.context.options, "account", account)
@@ -15,8 +30,33 @@ module BetterAuth
15
30
  end
16
31
 
17
32
  def self.unlink_account
18
- Endpoint.new(path: "/unlink-account", method: "POST") do |ctx|
19
- session = current_session(ctx, sensitive: true)
33
+ Endpoint.new(
34
+ path: "/unlink-account",
35
+ method: "POST",
36
+ body_schema: request_body_schema(
37
+ required_strings: %w[providerId],
38
+ optional_strings: %w[accountId]
39
+ ),
40
+ metadata: {
41
+ openapi: {
42
+ operationId: "unlinkAccount",
43
+ description: "Unlink an account from the current user",
44
+ requestBody: OpenAPI.json_request_body(
45
+ OpenAPI.object_schema(
46
+ {
47
+ providerId: {type: "string"},
48
+ accountId: {type: ["string", "null"]}
49
+ },
50
+ required: ["providerId"]
51
+ )
52
+ ),
53
+ responses: {
54
+ "200" => OpenAPI.json_response("Account unlinked", OpenAPI.status_response_schema)
55
+ }
56
+ }
57
+ }
58
+ ) do |ctx|
59
+ session = current_session(ctx, sensitive: true, fresh: true)
20
60
  body = normalize_hash(ctx.body)
21
61
  accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"])
22
62
  if accounts.length == 1 && !ctx.context.options.account.dig(:account_linking, :allow_unlinking_all)
@@ -24,6 +64,8 @@ module BetterAuth
24
64
  end
25
65
 
26
66
  provider_id = body["providerId"] || body["provider_id"]
67
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
68
+
27
69
  account_id = body["accountId"] || body["account_id"]
28
70
  account = accounts.find do |entry|
29
71
  entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id)
@@ -36,44 +78,108 @@ module BetterAuth
36
78
  end
37
79
 
38
80
  def self.get_access_token
39
- Endpoint.new(path: "/get-access-token", method: "POST") do |ctx|
81
+ Endpoint.new(
82
+ path: "/get-access-token",
83
+ method: "POST",
84
+ body_schema: request_body_schema(
85
+ required_strings: %w[providerId],
86
+ optional_strings: %w[accountId userId]
87
+ ),
88
+ metadata: {
89
+ openapi: {
90
+ operationId: "getAccessToken",
91
+ description: "Get an access token for a linked provider account",
92
+ requestBody: OpenAPI.json_request_body(
93
+ OpenAPI.object_schema(
94
+ {
95
+ providerId: {type: "string"},
96
+ accountId: {type: ["string", "null"]},
97
+ userId: {type: ["string", "null"]}
98
+ },
99
+ required: ["providerId"]
100
+ )
101
+ ),
102
+ responses: {
103
+ "200" => OpenAPI.json_response(
104
+ "Provider access token",
105
+ OpenAPI.object_schema(
106
+ {
107
+ accessToken: {type: ["string", "null"]},
108
+ accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
109
+ scopes: {type: "array", items: {type: "string"}},
110
+ idToken: {type: ["string", "null"]}
111
+ },
112
+ required: ["scopes"]
113
+ )
114
+ )
115
+ }
116
+ }
117
+ }
118
+ ) do |ctx|
40
119
  session = current_session(ctx, allow_nil: true)
41
120
  body = normalize_hash(ctx.body)
42
121
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
43
122
  raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
44
123
 
45
124
  provider_id = body["providerId"] || body["provider_id"]
46
- provider = social_provider(ctx.context, provider_id)
47
- raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
125
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
48
126
 
49
127
  account_id = body["accountId"] || body["account_id"]
50
- account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
51
- raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
52
-
53
- if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
54
- tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
55
- updated = update_account_tokens(ctx, account, tokens)
56
- account = account.merge(token_hash(tokens))
57
- Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
58
- end
59
-
60
- ctx.json({
61
- accessToken: oauth_token_value(ctx, account["accessToken"]),
62
- accessTokenExpiresAt: account["accessTokenExpiresAt"],
63
- scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
64
- idToken: account["idToken"]
65
- })
128
+ ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: account_id))
66
129
  end
67
130
  end
68
131
 
69
132
  def self.refresh_token
70
- Endpoint.new(path: "/refresh-token", method: "POST") do |ctx|
133
+ Endpoint.new(
134
+ path: "/refresh-token",
135
+ method: "POST",
136
+ body_schema: request_body_schema(
137
+ required_strings: %w[providerId],
138
+ optional_strings: %w[accountId userId]
139
+ ),
140
+ metadata: {
141
+ openapi: {
142
+ operationId: "refreshToken",
143
+ description: "Refresh an OAuth provider access token",
144
+ requestBody: OpenAPI.json_request_body(
145
+ OpenAPI.object_schema(
146
+ {
147
+ providerId: {type: "string"},
148
+ accountId: {type: ["string", "null"]},
149
+ userId: {type: ["string", "null"]}
150
+ },
151
+ required: ["providerId"]
152
+ )
153
+ ),
154
+ responses: {
155
+ "200" => OpenAPI.json_response(
156
+ "Refreshed provider tokens",
157
+ OpenAPI.object_schema(
158
+ {
159
+ accessToken: {type: ["string", "null"]},
160
+ refreshToken: {type: ["string", "null"]},
161
+ accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
162
+ refreshTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
163
+ scope: {type: ["string", "null"]},
164
+ idToken: {type: ["string", "null"]},
165
+ providerId: {type: "string"},
166
+ accountId: {type: "string"}
167
+ },
168
+ required: ["providerId", "accountId"]
169
+ )
170
+ )
171
+ }
172
+ }
173
+ }
174
+ ) do |ctx|
71
175
  session = current_session(ctx, allow_nil: true)
72
176
  body = normalize_hash(ctx.body)
73
177
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
74
178
  raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
75
179
 
76
180
  provider_id = body["providerId"] || body["provider_id"]
181
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
182
+
77
183
  provider = social_provider(ctx.context, provider_id)
78
184
  raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider
79
185
  raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token)
@@ -84,10 +190,15 @@ module BetterAuth
84
190
  refresh_token = oauth_token_value(ctx, account["refreshToken"])
85
191
  raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty?
86
192
 
87
- tokens = call_provider(provider, :refresh_access_token, refresh_token)
88
- updated = update_account_tokens(ctx, account, tokens)
89
- values = token_hash(tokens)
90
- Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
193
+ begin
194
+ tokens = call_provider(provider, :refresh_access_token, refresh_token)
195
+ updated = update_account_tokens(ctx, account, tokens)
196
+ values = token_hash(tokens)
197
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
198
+ rescue => error
199
+ log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.message}")
200
+ raise APIError.new("BAD_REQUEST", code: "FAILED_TO_REFRESH_ACCESS_TOKEN", message: "Failed to refresh access token")
201
+ end
91
202
  ctx.json({
92
203
  accessToken: values["accessToken"],
93
204
  refreshToken: values["refreshToken"] || refresh_token,
@@ -102,31 +213,85 @@ module BetterAuth
102
213
  end
103
214
 
104
215
  def self.account_info
105
- Endpoint.new(path: "/account-info", method: "GET") do |ctx|
216
+ Endpoint.new(
217
+ path: "/account-info",
218
+ method: "GET",
219
+ metadata: {
220
+ openapi: {
221
+ operationId: "accountInfo",
222
+ description: "Get user info from a linked provider account",
223
+ parameters: [
224
+ {
225
+ name: "accountId",
226
+ in: "query",
227
+ required: false,
228
+ schema: {type: "string"}
229
+ }
230
+ ],
231
+ responses: {
232
+ "200" => OpenAPI.json_response("Provider user info", {type: "object"})
233
+ }
234
+ }
235
+ }
236
+ ) do |ctx|
106
237
  session = current_session(ctx)
107
238
  account_id = fetch_value(ctx.query, "accountId")
108
239
  account = if account_id
109
240
  ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
110
241
  entry["id"] == account_id || entry["accountId"] == account_id
111
242
  end
243
+ else
244
+ account_cookie(ctx, nil, nil, session[:user]["id"])
112
245
  end
113
246
  raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
114
247
 
115
248
  provider = social_provider(ctx.context, account["providerId"])
116
249
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
117
- raise APIError.new("BAD_REQUEST", message: "Access token not found") if account["accessToken"].to_s.empty?
118
250
 
119
- info = call_provider(provider, :get_user_info, {
120
- accessToken: oauth_token_value(ctx, account["accessToken"]),
121
- access_token: oauth_token_value(ctx, account["accessToken"]),
122
- idToken: account["idToken"],
123
- scopes: account["scope"].to_s.split(",")
124
- })
251
+ tokens = access_token_response(
252
+ ctx,
253
+ user_id: session[:user]["id"],
254
+ provider_id: account["providerId"],
255
+ account_id: account["accountId"],
256
+ provider: provider
257
+ )
258
+ raise APIError.new("BAD_REQUEST", message: "Access token not found") if tokens[:accessToken].to_s.empty?
259
+
260
+ info = call_provider(provider, :get_user_info, tokens.merge(access_token: tokens[:accessToken]))
125
261
  ctx.json(info)
126
262
  end
127
263
  end
128
264
 
265
+ def self.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil)
266
+ provider ||= social_provider(ctx.context, provider_id)
267
+ raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
268
+
269
+ account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
270
+ raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
271
+
272
+ if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
273
+ begin
274
+ tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
275
+ updated = update_account_tokens(ctx, account, tokens)
276
+ account = account.merge(token_hash(tokens))
277
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
278
+ rescue => error
279
+ log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.message}")
280
+ raise APIError.new("BAD_REQUEST", code: "FAILED_TO_GET_ACCESS_TOKEN", message: "Failed to get a valid access token")
281
+ end
282
+ end
283
+
284
+ {
285
+ accessToken: oauth_token_value(ctx, account["accessToken"]),
286
+ accessTokenExpiresAt: account["accessTokenExpiresAt"],
287
+ scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
288
+ idToken: account["idToken"]
289
+ }
290
+ end
291
+
129
292
  def self.social_provider(context, provider_id)
293
+ return nil if provider_id.to_s.empty?
294
+
130
295
  provider = context.social_providers[provider_id.to_sym] || context.social_providers[provider_id.to_s]
131
296
  return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id")
132
297
 
@@ -143,7 +308,8 @@ module BetterAuth
143
308
  return nil unless ctx.context.options.account[:store_account_cookie]
144
309
 
145
310
  account = Cookies.get_account_cookie(ctx)
146
- return nil unless account && account["providerId"] == provider_id
311
+ return nil unless account
312
+ return nil if provider_id && account["providerId"] != provider_id
147
313
  return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id
148
314
  return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id
149
315
 
@@ -199,14 +365,14 @@ module BetterAuth
199
365
  return token if token.to_s.empty?
200
366
  return token unless ctx.context.options.account[:encrypt_oauth_tokens]
201
367
 
202
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
368
+ Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
203
369
  end
204
370
 
205
371
  def self.oauth_token_value(ctx, token)
206
372
  return token if token.to_s.empty?
207
373
  return token unless ctx.context.options.account[:encrypt_oauth_tokens]
208
374
 
209
- Crypto.symmetric_decrypt(key: ctx.context.secret, data: token) || token
375
+ Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: token) || token
210
376
  end
211
377
 
212
378
  def self.provider_callable(provider, key)
@@ -5,7 +5,28 @@ require "uri"
5
5
  module BetterAuth
6
6
  module Routes
7
7
  def self.send_verification_email
8
- Endpoint.new(path: "/send-verification-email", method: "POST") do |ctx|
8
+ Endpoint.new(
9
+ path: "/send-verification-email",
10
+ method: "POST",
11
+ metadata: {
12
+ openapi: {
13
+ operationId: "sendVerificationEmail",
14
+ description: "Send an email verification link",
15
+ requestBody: OpenAPI.json_request_body(
16
+ OpenAPI.object_schema(
17
+ {
18
+ email: {type: "string", description: "The email address to verify"},
19
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after verification"}
20
+ },
21
+ required: ["email"]
22
+ )
23
+ ),
24
+ responses: {
25
+ "200" => OpenAPI.json_response("Verification email sent", OpenAPI.status_response_schema)
26
+ }
27
+ }
28
+ }
29
+ ) do |ctx|
9
30
  sender = ctx.context.options.email_verification[:send_verification_email]
10
31
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
11
32
 
@@ -32,7 +53,42 @@ module BetterAuth
32
53
  end
33
54
 
34
55
  def self.verify_email
35
- Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
56
+ Endpoint.new(
57
+ path: "/verify-email",
58
+ method: "GET",
59
+ metadata: {
60
+ openapi: {
61
+ operationId: "verifyEmail",
62
+ description: "Verify an email address by token",
63
+ parameters: [
64
+ {
65
+ name: "token",
66
+ in: "query",
67
+ required: true,
68
+ schema: {type: "string"}
69
+ },
70
+ {
71
+ name: "callbackURL",
72
+ in: "query",
73
+ required: false,
74
+ schema: {type: "string"}
75
+ }
76
+ ],
77
+ responses: {
78
+ "200" => OpenAPI.json_response(
79
+ "Email verified",
80
+ OpenAPI.object_schema(
81
+ {
82
+ status: {type: "boolean"},
83
+ user: {type: ["object", "null"], "$ref": "#/components/schemas/User"}
84
+ },
85
+ required: ["status"]
86
+ )
87
+ )
88
+ }
89
+ }
90
+ }
91
+ ) do |ctx|
36
92
  token = fetch_value(ctx.query, "token").to_s
37
93
  callback_url = fetch_value(ctx.query, "callbackURL")
38
94
  validate_callback_url!(ctx.context, callback_url)
@@ -44,11 +100,27 @@ module BetterAuth
44
100
 
45
101
  user = user_data[:user]
46
102
  if update_to
47
- updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
48
- updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
49
- send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
50
- set_verified_session_cookie(ctx, updated_user)
51
- next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
103
+ session = current_session(ctx, allow_nil: true)
104
+ return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email
105
+
106
+ request_type = payload["requestType"] || payload["request_type"]
107
+ case request_type
108
+ when "change-email-confirmation"
109
+ send_change_email_verification_payload(ctx, user, update_to, callback_url)
110
+ next redirect_or_json(ctx, callback_url, {status: true})
111
+ when "change-email-verification"
112
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
113
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => true)
114
+ call_option(ctx.context.options.email_verification[:after_email_verification], updated_user, ctx.request)
115
+ set_verified_session_cookie(ctx, updated_user)
116
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
117
+ else
118
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
119
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
120
+ send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
121
+ set_verified_session_cookie(ctx, updated_user)
122
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
123
+ end
52
124
  end
53
125
 
54
126
  if user["emailVerified"]
@@ -71,6 +143,16 @@ module BetterAuth
71
143
  ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
72
144
  end
73
145
 
146
+ def self.send_change_email_verification_payload(ctx, user, update_to, callback_url)
147
+ sender = ctx.context.options.email_verification[:send_verification_email]
148
+ return unless sender.respond_to?(:call)
149
+
150
+ token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"})
151
+ callback = URI.encode_www_form_component(callback_url || "/")
152
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
153
+ sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request)
154
+ end
155
+
74
156
  def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
75
157
  payload = {"email" => email.to_s.downcase}.merge(extra)
76
158
  payload["updateTo"] = update_to if update_to
@@ -78,18 +160,20 @@ module BetterAuth
78
160
  end
79
161
 
80
162
  def self.verify_email_token(ctx, token, callback_url)
81
- payload = Crypto.verify_jwt(token, ctx.context.secret)
82
- return payload if payload
83
-
84
- redirect_or_error(ctx, callback_url, "invalid_token")
163
+ decoded, = JWT.decode(token.to_s, ctx.context.secret.to_s, true, algorithm: "HS256")
164
+ decoded
165
+ rescue JWT::ExpiredSignature
166
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_EXPIRED"], code: "TOKEN_EXPIRED")
167
+ rescue JWT::DecodeError
168
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN")
85
169
  end
86
170
 
87
- def self.redirect_or_error(ctx, callback_url, error)
171
+ def self.redirect_or_error(ctx, callback_url, error, code: nil)
88
172
  if callback_url
89
173
  separator = callback_url.include?("?") ? "&" : "?"
90
- raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
174
+ raise ctx.redirect("#{callback_url}#{separator}error=#{code || error}")
91
175
  end
92
- raise APIError.new("UNAUTHORIZED", message: error)
176
+ raise APIError.new("UNAUTHORIZED", code: code, message: error)
93
177
  end
94
178
 
95
179
  def self.redirect_or_json(ctx, callback_url, data)
@@ -8,14 +8,46 @@ module BetterAuth
8
8
  PASSWORD_RESET_MESSAGE = "If this email exists in our system, check your email for the reset link"
9
9
 
10
10
  def self.request_password_reset
11
- Endpoint.new(path: "/request-password-reset", method: "POST") do |ctx|
11
+ Endpoint.new(
12
+ path: "/request-password-reset",
13
+ method: "POST",
14
+ body_schema: request_body_schema(email_strings: %w[email]),
15
+ metadata: {
16
+ openapi: {
17
+ operationId: "requestPasswordReset",
18
+ description: "Request a password reset link",
19
+ requestBody: OpenAPI.json_request_body(
20
+ OpenAPI.object_schema(
21
+ {
22
+ email: {type: "string", description: "The email address of the user"},
23
+ redirectTo: {type: ["string", "null"], description: "The URL to redirect to after reset"}
24
+ },
25
+ required: ["email"]
26
+ )
27
+ ),
28
+ responses: {
29
+ "200" => OpenAPI.json_response(
30
+ "Password reset request processed",
31
+ OpenAPI.status_response_schema(
32
+ {
33
+ message: {type: "string"}
34
+ },
35
+ required: ["status", "message"]
36
+ )
37
+ )
38
+ }
39
+ }
40
+ }
41
+ ) do |ctx|
12
42
  sender = ctx.context.options.email_and_password[:send_reset_password]
13
- raise APIError.new("BAD_REQUEST", message: "Reset password isn't enabled") unless sender.respond_to?(:call)
43
+ unless sender.respond_to?(:call)
44
+ raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"])
45
+ end
14
46
 
15
47
  body = normalize_hash(ctx.body)
16
48
  email = body["email"].to_s.downcase
17
49
  redirect_to = body["redirectTo"] || body["redirect_to"]
18
- validate_callback_url!(ctx.context, redirect_to)
50
+ validate_redirect_url!(ctx.context, redirect_to)
19
51
  found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
20
52
  unless found
21
53
  SecureRandom.hex(12)
@@ -33,15 +65,46 @@ module BetterAuth
33
65
 
34
66
  callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
35
67
  url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
36
- sender.call({user: found[:user], url: url, token: token}, ctx.request)
68
+ begin
69
+ sender.call({user: found[:user], url: url, token: token}, ctx.request)
70
+ rescue => error
71
+ log(ctx.context, :error, "RESET_PASSWORD_EMAIL_ERROR #{error.message}")
72
+ end
37
73
  ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
38
74
  end
39
75
  end
40
76
 
41
77
  def self.request_password_reset_callback
42
- Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
78
+ Endpoint.new(
79
+ path: "/reset-password/:token",
80
+ method: "GET",
81
+ query_schema: request_query_schema(required_strings: %w[callbackURL]),
82
+ metadata: {
83
+ openapi: {
84
+ operationId: "requestPasswordResetCallback",
85
+ description: "Validate a password reset token and redirect to the callback URL",
86
+ parameters: [
87
+ {
88
+ name: "token",
89
+ in: "path",
90
+ required: true,
91
+ schema: {type: "string"}
92
+ },
93
+ {
94
+ name: "callbackURL",
95
+ in: "query",
96
+ required: true,
97
+ schema: {type: "string"}
98
+ }
99
+ ],
100
+ responses: {
101
+ "302" => {description: "Redirects to callback URL with token or error"}
102
+ }
103
+ }
104
+ }
105
+ ) do |ctx|
43
106
  token = ctx.params[:token].to_s
44
- callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
107
+ callback_url = fetch_value(ctx.query, "callbackURL")
45
108
  validate_callback_url!(ctx.context, callback_url)
46
109
  verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
47
110
 
@@ -54,7 +117,33 @@ module BetterAuth
54
117
  end
55
118
 
56
119
  def self.reset_password
57
- Endpoint.new(path: "/reset-password", method: "POST") do |ctx|
120
+ Endpoint.new(
121
+ path: "/reset-password",
122
+ method: "POST",
123
+ body_schema: request_body_schema(
124
+ required_strings: %w[newPassword],
125
+ optional_strings: %w[token]
126
+ ),
127
+ query_schema: request_query_schema(optional_strings: %w[token]),
128
+ metadata: {
129
+ openapi: {
130
+ operationId: "resetPassword",
131
+ description: "Reset a password using a reset token",
132
+ requestBody: OpenAPI.json_request_body(
133
+ OpenAPI.object_schema(
134
+ {
135
+ token: {type: "string", description: "The password reset token"},
136
+ newPassword: {type: "string", description: "The new password to set"}
137
+ },
138
+ required: ["token", "newPassword"]
139
+ )
140
+ ),
141
+ responses: {
142
+ "200" => OpenAPI.json_response("Password reset successfully", OpenAPI.status_response_schema)
143
+ }
144
+ }
145
+ }
146
+ ) do |ctx|
58
147
  body = normalize_hash(ctx.body)
59
148
  token = body["token"] || fetch_value(ctx.query, "token")
60
149
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
@@ -86,7 +175,27 @@ module BetterAuth
86
175
  end
87
176
 
88
177
  def self.verify_password
89
- Endpoint.new(path: "/verify-password", method: "POST") do |ctx|
178
+ Endpoint.new(
179
+ path: "/verify-password",
180
+ method: "POST",
181
+ metadata: {
182
+ openapi: {
183
+ operationId: "verifyPassword",
184
+ description: "Verify the current user's password",
185
+ requestBody: OpenAPI.json_request_body(
186
+ OpenAPI.object_schema(
187
+ {
188
+ password: {type: "string", description: "The password to verify"}
189
+ },
190
+ required: ["password"]
191
+ )
192
+ ),
193
+ responses: {
194
+ "200" => OpenAPI.json_response("Password verified", OpenAPI.status_response_schema)
195
+ }
196
+ }
197
+ }
198
+ ) do |ctx|
90
199
  session = current_session(ctx, sensitive: true)
91
200
  password = normalize_hash(ctx.body)["password"].to_s
92
201
  account = credential_account(ctx, session[:user]["id"])
@@ -180,5 +289,13 @@ module BetterAuth
180
289
 
181
290
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
182
291
  end
292
+
293
+ def self.validate_redirect_url!(context, redirect_url)
294
+ validate_callback_url!(context, redirect_url)
295
+ rescue APIError => error
296
+ raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
297
+
298
+ raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["INVALID_REDIRECT_URL"])
299
+ end
183
300
  end
184
301
  end