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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +10 -7
- data/lib/better_auth/adapters/memory.rb +57 -11
- data/lib/better_auth/adapters/sql.rb +123 -20
- data/lib/better_auth/api.rb +114 -9
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +8 -8
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +92 -5
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +261 -19
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +67 -35
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +186 -56
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +118 -41
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +38 -0
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +220 -42
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +126 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +26 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +70 -4
- data/lib/better_auth/routes/social.rb +132 -7
- data/lib/better_auth/routes/user.rb +228 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +13 -2
- data/lib/better_auth/url_helpers.rb +206 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +12 -0
- 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",
|
|
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 =
|
|
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(
|
|
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/:
|
|
111
|
+
path: "/callback/:id",
|
|
70
112
|
method: ["GET", "POST"],
|
|
71
|
-
metadata: {
|
|
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/#{
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: "
|
|
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(
|
|
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 +
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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: [])
|