better_auth 0.4.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 +2 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -5
- data/lib/better_auth/adapters/sql.rb +96 -18
- data/lib/better_auth/api.rb +113 -13
- 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 +5 -5
- data/lib/better_auth/endpoint.rb +87 -3
- data/lib/better_auth/error.rb +8 -1
- 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 +246 -15
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +53 -7
- 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 +2 -2
- 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.rb +135 -36
- 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 +65 -23
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +20 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +204 -38
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +125 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +24 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +62 -4
- data/lib/better_auth/routes/social.rb +102 -7
- data/lib/better_auth/routes/user.rb +222 -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 +1 -1
- data/lib/better_auth/url_helpers.rb +12 -1
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +4 -0
- metadata +15 -1
|
@@ -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
|
|
|
@@ -80,12 +185,14 @@ module BetterAuth
|
|
|
80
185
|
elsif sender
|
|
81
186
|
token = SecureRandom.hex(16)
|
|
82
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)}"
|
|
83
190
|
ctx.context.internal_adapter.create_verification_value(
|
|
84
191
|
identifier: "delete-account-#{token}",
|
|
85
192
|
value: session[:user]["id"],
|
|
86
193
|
expiresAt: Time.now + expires_in.to_i
|
|
87
194
|
)
|
|
88
|
-
sender.call({user: session[:user], token: token}, ctx.request)
|
|
195
|
+
sender.call({user: session[:user], url: url, token: token}, ctx.request)
|
|
89
196
|
next ctx.json({success: true, message: "Verification email sent"})
|
|
90
197
|
elsif !body["password"]
|
|
91
198
|
require_fresh_session!(ctx, session)
|
|
@@ -97,14 +204,49 @@ module BetterAuth
|
|
|
97
204
|
end
|
|
98
205
|
|
|
99
206
|
def self.delete_user_callback
|
|
100
|
-
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|
|
|
101
243
|
enabled = ctx.context.options.user.dig(:delete_user, :enabled)
|
|
102
244
|
raise APIError.new("NOT_FOUND") unless enabled
|
|
103
245
|
session = current_session(ctx)
|
|
104
246
|
token = fetch_value(ctx.query, "token")
|
|
105
|
-
delete_user_by_token!(ctx, session, token)
|
|
106
247
|
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
107
248
|
validate_callback_url!(ctx.context, callback_url)
|
|
249
|
+
delete_user_by_token!(ctx, session, token)
|
|
108
250
|
delete_current_user!(ctx, session)
|
|
109
251
|
raise ctx.redirect(callback_url) if callback_url
|
|
110
252
|
|
|
@@ -113,7 +255,43 @@ module BetterAuth
|
|
|
113
255
|
end
|
|
114
256
|
|
|
115
257
|
def self.change_email
|
|
116
|
-
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|
|
|
117
295
|
enabled = ctx.context.options.user.dig(:change_email, :enabled)
|
|
118
296
|
raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
|
|
119
297
|
session = current_session(ctx, sensitive: true)
|
|
@@ -121,26 +299,48 @@ module BetterAuth
|
|
|
121
299
|
new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
|
|
122
300
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
|
|
123
301
|
raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
|
|
124
|
-
|
|
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
|
|
125
310
|
|
|
126
|
-
|
|
127
|
-
|
|
311
|
+
existing_target = ctx.context.internal_adapter.find_user_by_email(new_email)
|
|
312
|
+
next ctx.json({status: true}) if existing_target
|
|
128
313
|
|
|
314
|
+
if can_update_without_verification
|
|
129
315
|
updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
|
|
130
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
|
|
131
318
|
next ctx.json({status: true})
|
|
132
319
|
end
|
|
133
320
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
137
328
|
|
|
138
|
-
|
|
139
|
-
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"])
|
|
140
330
|
ctx.json({status: true})
|
|
141
331
|
end
|
|
142
332
|
end
|
|
143
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
|
+
|
|
144
344
|
def self.delete_user_by_token!(ctx, session, token)
|
|
145
345
|
verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
|
|
146
346
|
unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
|
|
@@ -164,8 +364,10 @@ module BetterAuth
|
|
|
164
364
|
fresh_age = ctx.context.session_config[:fresh_age].to_i
|
|
165
365
|
return if fresh_age <= 0
|
|
166
366
|
|
|
167
|
-
|
|
168
|
-
|
|
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"])
|
|
169
371
|
end
|
|
170
372
|
|
|
171
373
|
def self.parse_declared_input(ctx, model, data, allowed_base: [])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Routes
|
|
5
|
+
REQUEST_EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
6
|
+
|
|
7
|
+
def self.request_body_schema(required_strings: [], required_nonempty_strings: [], email_strings: [], optional_strings: [])
|
|
8
|
+
->(body) {
|
|
9
|
+
data = request_validation_hash(body)
|
|
10
|
+
return false unless required_strings.all? { |key| request_string?(data, key) }
|
|
11
|
+
return false unless required_nonempty_strings.all? { |key| request_string?(data, key) && !data[request_storage_key(key)].empty? }
|
|
12
|
+
return false unless email_strings.all? { |key| request_string?(data, key) && REQUEST_EMAIL_PATTERN.match?(data[request_storage_key(key)]) }
|
|
13
|
+
return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) }
|
|
14
|
+
|
|
15
|
+
data
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.request_query_schema(required_strings: [], optional_strings: [])
|
|
20
|
+
->(query) {
|
|
21
|
+
data = request_validation_hash(query)
|
|
22
|
+
return false unless required_strings.all? { |key| request_string?(data, key) }
|
|
23
|
+
return false unless optional_strings.all? { |key| !data.key?(request_storage_key(key)) || request_string?(data, key) }
|
|
24
|
+
|
|
25
|
+
data
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.request_validation_hash(value)
|
|
30
|
+
return {} unless value.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
value.each_with_object({}) do |(key, object_value), result|
|
|
33
|
+
result[request_storage_key(key)] = object_value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.request_string?(data, key)
|
|
38
|
+
data[request_storage_key(key)].is_a?(String)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.request_storage_key(key)
|
|
42
|
+
key.to_s
|
|
43
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
44
|
+
.tr("-", "_")
|
|
45
|
+
.downcase
|
|
46
|
+
.split("_")
|
|
47
|
+
.then { |parts| ([parts.first] + parts.drop(1).map(&:capitalize)).join }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
class SecretConfig
|
|
5
|
+
ENVELOPE_PREFIX = "$ba$"
|
|
6
|
+
|
|
7
|
+
attr_reader :keys, :current_version, :legacy_secret
|
|
8
|
+
|
|
9
|
+
def initialize(keys:, current_version:, legacy_secret: nil)
|
|
10
|
+
normalized_keys = keys.each_with_object({}) do |(version, value), result|
|
|
11
|
+
result[normalize_version!(version)] = value.to_s
|
|
12
|
+
end
|
|
13
|
+
@keys = normalized_keys.freeze
|
|
14
|
+
@current_version = normalize_version!(current_version)
|
|
15
|
+
@legacy_secret = legacy_secret unless legacy_secret.to_s.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_secret
|
|
19
|
+
keys.fetch(current_version) do
|
|
20
|
+
raise Error, "Secret version #{current_version} not found in keys"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def all_secrets
|
|
25
|
+
entries = keys.map { |version, value| [version, value] }
|
|
26
|
+
entries << [-1, legacy_secret] if legacy_secret && !keys.value?(legacy_secret)
|
|
27
|
+
entries
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse_env(value)
|
|
31
|
+
return nil if value.to_s.empty?
|
|
32
|
+
|
|
33
|
+
value.to_s.split(",").map do |entry|
|
|
34
|
+
entry = entry.strip
|
|
35
|
+
colon_index = entry.index(":")
|
|
36
|
+
raise Error, "Invalid BETTER_AUTH_SECRETS entry: \"#{entry}\". Expected format: \"<version>:<secret>\"" unless colon_index
|
|
37
|
+
|
|
38
|
+
version = entry[0...colon_index].strip
|
|
39
|
+
secret = entry[(colon_index + 1)..].to_s.strip
|
|
40
|
+
raise Error, "Empty secret value for version #{version} in BETTER_AUTH_SECRETS." if secret.empty?
|
|
41
|
+
|
|
42
|
+
{version: parse_version!(version, source: "BETTER_AUTH_SECRETS"), value: secret}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.validate_secrets!(secrets, logger: nil)
|
|
47
|
+
entries = Array(secrets)
|
|
48
|
+
raise Error, "`secrets` array must contain at least one entry." if entries.empty?
|
|
49
|
+
|
|
50
|
+
seen = {}
|
|
51
|
+
entries.each do |entry|
|
|
52
|
+
data = normalize_entry(entry)
|
|
53
|
+
version = parse_version!(data.fetch(:version), source: "`secrets`")
|
|
54
|
+
value = data.fetch(:value, nil).to_s
|
|
55
|
+
raise Error, "Empty secret value for version #{version} in `secrets`." if value.empty?
|
|
56
|
+
raise Error, "Duplicate version #{version} in `secrets`. Each version must be unique." if seen[version]
|
|
57
|
+
|
|
58
|
+
seen[version] = true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
current = normalize_entry(entries.first)
|
|
62
|
+
current_version = parse_version!(current.fetch(:version), source: "`secrets`")
|
|
63
|
+
current_value = current.fetch(:value).to_s
|
|
64
|
+
warn(logger, "[better-auth] Warning: the current secret (version #{current_version}) should be at least 32 characters long for adequate security.") if current_value.length < 32
|
|
65
|
+
warn(logger, "[better-auth] Warning: the current secret appears low-entropy. Use a randomly generated secret for production.") if entropy(current_value) < 120
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.build(secrets, legacy_secret, logger: nil)
|
|
69
|
+
validate_secrets!(secrets, logger: logger)
|
|
70
|
+
entries = Array(secrets).map { |entry| normalize_entry(entry) }
|
|
71
|
+
keys = entries.each_with_object({}) do |entry, result|
|
|
72
|
+
result[parse_version!(entry.fetch(:version), source: "`secrets`")] = entry.fetch(:value).to_s
|
|
73
|
+
end
|
|
74
|
+
current_version = parse_version!(entries.first.fetch(:version), source: "`secrets`")
|
|
75
|
+
legacy = (legacy_secret && legacy_secret != Configuration::DEFAULT_SECRET) ? legacy_secret : nil
|
|
76
|
+
new(keys: keys, current_version: current_version, legacy_secret: legacy)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.normalize_entry(entry)
|
|
80
|
+
raise Error, "Invalid `secrets` entry. Expected a hash with `version` and `value`." unless entry.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
entry.each_with_object({}) do |(key, value), result|
|
|
83
|
+
result[key.to_s.tr("-", "_").to_sym] = value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.parse_version!(value, source:)
|
|
88
|
+
text = value.to_s.strip
|
|
89
|
+
unless text.match?(/\A(?:0|[1-9]\d*)\z/)
|
|
90
|
+
raise Error, "Invalid version #{value} in #{source}. Version must be a non-negative integer."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
text.to_i
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.entropy(value)
|
|
97
|
+
unique = value.to_s.chars.uniq.length
|
|
98
|
+
return 0 if unique.zero?
|
|
99
|
+
|
|
100
|
+
Math.log2(unique**value.to_s.length)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.warn(logger, message)
|
|
104
|
+
if logger.respond_to?(:call)
|
|
105
|
+
logger.call(:warn, message)
|
|
106
|
+
elsif logger.respond_to?(:warn)
|
|
107
|
+
logger.warn(message)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize_version!(version)
|
|
112
|
+
self.class.parse_version!(version, source: "`secrets`")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/better_auth/session.rb
CHANGED
|
@@ -108,7 +108,7 @@ module BetterAuth
|
|
|
108
108
|
update_age = if refresh_cache.is_a?(Hash)
|
|
109
109
|
(refresh_cache[:update_age] || refresh_cache["updateAge"] || refresh_cache["update_age"]).to_i
|
|
110
110
|
else
|
|
111
|
-
(max_age * 0.
|
|
111
|
+
(max_age * 0.2).to_i
|
|
112
112
|
end
|
|
113
113
|
updated_at = payload["updatedAt"].to_i
|
|
114
114
|
updated_at.positive? && updated_at + (update_age * 1000) <= (Time.now.to_f * 1000).to_i
|
|
@@ -153,6 +153,7 @@ module BetterAuth
|
|
|
153
153
|
def headers_from_source(source)
|
|
154
154
|
return {} unless source
|
|
155
155
|
return source.headers if source.respond_to?(:headers)
|
|
156
|
+
return rack_request_headers(source) if source.respond_to?(:get_header)
|
|
156
157
|
return source if source.is_a?(Hash)
|
|
157
158
|
|
|
158
159
|
{}
|
|
@@ -167,7 +168,9 @@ module BetterAuth
|
|
|
167
168
|
end
|
|
168
169
|
|
|
169
170
|
def source_url(source)
|
|
170
|
-
source.url if source.respond_to?(:url)
|
|
171
|
+
return source.url if source.respond_to?(:url)
|
|
172
|
+
|
|
173
|
+
source.get_header("REQUEST_URI") if source.respond_to?(:get_header)
|
|
171
174
|
end
|
|
172
175
|
|
|
173
176
|
def dynamic_config?(config)
|
|
@@ -191,5 +194,13 @@ module BetterAuth
|
|
|
191
194
|
|
|
192
195
|
port.to_i.between?(1, 65_535)
|
|
193
196
|
end
|
|
197
|
+
|
|
198
|
+
def rack_request_headers(source)
|
|
199
|
+
{
|
|
200
|
+
"x-forwarded-host" => source.get_header("HTTP_X_FORWARDED_HOST"),
|
|
201
|
+
"x-forwarded-proto" => source.get_header("HTTP_X_FORWARDED_PROTO"),
|
|
202
|
+
"host" => source.get_header("HTTP_HOST")
|
|
203
|
+
}.compact
|
|
204
|
+
end
|
|
194
205
|
end
|
|
195
206
|
end
|
data/lib/better_auth/version.rb
CHANGED
data/lib/better_auth.rb
CHANGED
|
@@ -4,10 +4,12 @@ require_relative "better_auth/version"
|
|
|
4
4
|
require_relative "better_auth/core"
|
|
5
5
|
require_relative "better_auth/error"
|
|
6
6
|
require_relative "better_auth/api_error"
|
|
7
|
+
require_relative "better_auth/secret_config"
|
|
7
8
|
require_relative "better_auth/crypto"
|
|
8
9
|
require_relative "better_auth/host"
|
|
9
10
|
require_relative "better_auth/url_helpers"
|
|
10
11
|
require_relative "better_auth/request_state"
|
|
12
|
+
require_relative "better_auth/response"
|
|
11
13
|
require_relative "better_auth/async"
|
|
12
14
|
require_relative "better_auth/deprecate"
|
|
13
15
|
require_relative "better_auth/logger"
|
|
@@ -48,6 +50,7 @@ require_relative "better_auth/plugins/one_time_token"
|
|
|
48
50
|
require_relative "better_auth/plugins/one_tap"
|
|
49
51
|
require_relative "better_auth/plugins/siwe"
|
|
50
52
|
require_relative "better_auth/plugins/generic_oauth"
|
|
53
|
+
require_relative "better_auth/plugins/dub"
|
|
51
54
|
require_relative "better_auth/plugins/oauth_proxy"
|
|
52
55
|
require_relative "better_auth/plugins/passkey"
|
|
53
56
|
require_relative "better_auth/plugins/organization/schema"
|
|
@@ -76,6 +79,7 @@ require_relative "better_auth/session_store"
|
|
|
76
79
|
require_relative "better_auth/cookies"
|
|
77
80
|
require_relative "better_auth/session"
|
|
78
81
|
require_relative "better_auth/endpoint"
|
|
82
|
+
require_relative "better_auth/routes/validation"
|
|
79
83
|
require_relative "better_auth/routes/ok"
|
|
80
84
|
require_relative "better_auth/routes/error"
|
|
81
85
|
require_relative "better_auth/routes/sign_up"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: better_auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sebastian Sala
|
|
@@ -300,6 +300,7 @@ files:
|
|
|
300
300
|
- lib/better_auth/plugins/captcha.rb
|
|
301
301
|
- lib/better_auth/plugins/custom_session.rb
|
|
302
302
|
- lib/better_auth/plugins/device_authorization.rb
|
|
303
|
+
- lib/better_auth/plugins/dub.rb
|
|
303
304
|
- lib/better_auth/plugins/email_otp.rb
|
|
304
305
|
- lib/better_auth/plugins/expo.rb
|
|
305
306
|
- lib/better_auth/plugins/generic_oauth.rb
|
|
@@ -308,6 +309,16 @@ files:
|
|
|
308
309
|
- lib/better_auth/plugins/last_login_method.rb
|
|
309
310
|
- lib/better_auth/plugins/magic_link.rb
|
|
310
311
|
- lib/better_auth/plugins/mcp.rb
|
|
312
|
+
- lib/better_auth/plugins/mcp/authorization.rb
|
|
313
|
+
- lib/better_auth/plugins/mcp/config.rb
|
|
314
|
+
- lib/better_auth/plugins/mcp/consent.rb
|
|
315
|
+
- lib/better_auth/plugins/mcp/legacy_aliases.rb
|
|
316
|
+
- lib/better_auth/plugins/mcp/metadata.rb
|
|
317
|
+
- lib/better_auth/plugins/mcp/registration.rb
|
|
318
|
+
- lib/better_auth/plugins/mcp/resource_handler.rb
|
|
319
|
+
- lib/better_auth/plugins/mcp/schema.rb
|
|
320
|
+
- lib/better_auth/plugins/mcp/token.rb
|
|
321
|
+
- lib/better_auth/plugins/mcp/userinfo.rb
|
|
311
322
|
- lib/better_auth/plugins/multi_session.rb
|
|
312
323
|
- lib/better_auth/plugins/oauth_protocol.rb
|
|
313
324
|
- lib/better_auth/plugins/oauth_provider.rb
|
|
@@ -329,6 +340,7 @@ files:
|
|
|
329
340
|
- lib/better_auth/rate_limiter.rb
|
|
330
341
|
- lib/better_auth/request_ip.rb
|
|
331
342
|
- lib/better_auth/request_state.rb
|
|
343
|
+
- lib/better_auth/response.rb
|
|
332
344
|
- lib/better_auth/router.rb
|
|
333
345
|
- lib/better_auth/routes/account.rb
|
|
334
346
|
- lib/better_auth/routes/email_verification.rb
|
|
@@ -341,8 +353,10 @@ files:
|
|
|
341
353
|
- lib/better_auth/routes/sign_up.rb
|
|
342
354
|
- lib/better_auth/routes/social.rb
|
|
343
355
|
- lib/better_auth/routes/user.rb
|
|
356
|
+
- lib/better_auth/routes/validation.rb
|
|
344
357
|
- lib/better_auth/schema.rb
|
|
345
358
|
- lib/better_auth/schema/sql.rb
|
|
359
|
+
- lib/better_auth/secret_config.rb
|
|
346
360
|
- lib/better_auth/session.rb
|
|
347
361
|
- lib/better_auth/session_store.rb
|
|
348
362
|
- lib/better_auth/social_providers.rb
|