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
|
@@ -5,7 +5,28 @@ require "uri"
|
|
|
5
5
|
module BetterAuth
|
|
6
6
|
module Routes
|
|
7
7
|
def self.send_verification_email
|
|
8
|
-
Endpoint.new(
|
|
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(
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
redirect_or_error(ctx, callback_url, "
|
|
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,12 +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(
|
|
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
|
-
|
|
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
|
|
49
|
+
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
50
|
+
validate_redirect_url!(ctx.context, redirect_to)
|
|
17
51
|
found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
|
|
18
52
|
unless found
|
|
19
53
|
SecureRandom.hex(12)
|
|
@@ -29,18 +63,48 @@ module BetterAuth
|
|
|
29
63
|
expiresAt: Time.now + expires_in.to_i
|
|
30
64
|
)
|
|
31
65
|
|
|
32
|
-
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
33
66
|
callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
|
|
34
67
|
url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
|
|
35
|
-
|
|
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
|
|
36
73
|
ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
|
|
37
74
|
end
|
|
38
75
|
end
|
|
39
76
|
|
|
40
77
|
def self.request_password_reset_callback
|
|
41
|
-
Endpoint.new(
|
|
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|
|
|
42
106
|
token = ctx.params[:token].to_s
|
|
43
|
-
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
107
|
+
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
44
108
|
validate_callback_url!(ctx.context, callback_url)
|
|
45
109
|
verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
|
|
46
110
|
|
|
@@ -53,7 +117,33 @@ module BetterAuth
|
|
|
53
117
|
end
|
|
54
118
|
|
|
55
119
|
def self.reset_password
|
|
56
|
-
Endpoint.new(
|
|
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|
|
|
57
147
|
body = normalize_hash(ctx.body)
|
|
58
148
|
token = body["token"] || fetch_value(ctx.query, "token")
|
|
59
149
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
|
|
@@ -85,7 +175,27 @@ module BetterAuth
|
|
|
85
175
|
end
|
|
86
176
|
|
|
87
177
|
def self.verify_password
|
|
88
|
-
Endpoint.new(
|
|
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|
|
|
89
199
|
session = current_session(ctx, sensitive: true)
|
|
90
200
|
password = normalize_hash(ctx.body)["password"].to_s
|
|
91
201
|
account = credential_account(ctx, session[:user]["id"])
|
|
@@ -179,5 +289,13 @@ module BetterAuth
|
|
|
179
289
|
|
|
180
290
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
|
|
181
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
|
|
182
300
|
end
|
|
183
301
|
end
|
|
@@ -3,11 +3,34 @@
|
|
|
3
3
|
module BetterAuth
|
|
4
4
|
module Routes
|
|
5
5
|
def self.get_session
|
|
6
|
-
Endpoint.new(
|
|
7
|
-
session
|
|
6
|
+
Endpoint.new(
|
|
7
|
+
path: "/get-session",
|
|
8
|
+
method: ["GET", "POST"],
|
|
9
|
+
metadata: {
|
|
10
|
+
openapi: {
|
|
11
|
+
operationId: "getSession",
|
|
12
|
+
description: "Get the current session",
|
|
13
|
+
responses: {
|
|
14
|
+
"200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(nullable: true))
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
) do |ctx|
|
|
19
|
+
defer_refresh = ctx.context.session_config[:defer_session_refresh]
|
|
20
|
+
if ctx.method == "POST" && !defer_refresh
|
|
21
|
+
raise APIError.new(
|
|
22
|
+
"METHOD_NOT_ALLOWED",
|
|
23
|
+
code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
|
|
24
|
+
message: BASE_ERROR_CODES["METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED"]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
session = current_session(ctx, allow_nil: true, disable_refresh: defer_refresh && ctx.method == "GET")
|
|
8
29
|
next ctx.json(nil) unless session
|
|
9
30
|
|
|
10
|
-
|
|
31
|
+
response = parsed_session_response(ctx, session)
|
|
32
|
+
response[:needsRefresh] = session_needs_refresh?(ctx, session[:session]) if defer_refresh && ctx.method == "GET"
|
|
33
|
+
ctx.json(response)
|
|
11
34
|
rescue APIError
|
|
12
35
|
raise
|
|
13
36
|
rescue => error
|
|
@@ -17,7 +40,22 @@ module BetterAuth
|
|
|
17
40
|
end
|
|
18
41
|
|
|
19
42
|
def self.list_sessions
|
|
20
|
-
Endpoint.new(
|
|
43
|
+
Endpoint.new(
|
|
44
|
+
path: "/list-sessions",
|
|
45
|
+
method: "GET",
|
|
46
|
+
metadata: {
|
|
47
|
+
openapi: {
|
|
48
|
+
operationId: "listSessions",
|
|
49
|
+
description: "List active sessions for the current user",
|
|
50
|
+
responses: {
|
|
51
|
+
"200" => OpenAPI.json_response(
|
|
52
|
+
"Active sessions",
|
|
53
|
+
{type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}}
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
) do |ctx|
|
|
21
59
|
session = current_session(ctx)
|
|
22
60
|
sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
|
|
23
61
|
active = sessions
|
|
@@ -29,8 +67,22 @@ module BetterAuth
|
|
|
29
67
|
end
|
|
30
68
|
|
|
31
69
|
def self.update_session
|
|
32
|
-
Endpoint.new(
|
|
33
|
-
session
|
|
70
|
+
Endpoint.new(
|
|
71
|
+
path: "/update-session",
|
|
72
|
+
method: "POST",
|
|
73
|
+
metadata: {
|
|
74
|
+
openapi: {
|
|
75
|
+
operationId: "updateSession",
|
|
76
|
+
description: "Update the current session",
|
|
77
|
+
responses: {
|
|
78
|
+
"200" => OpenAPI.json_response("Updated session", OpenAPI.session_response_schema_pair)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
) do |ctx|
|
|
83
|
+
session = current_session(ctx)
|
|
84
|
+
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)
|
|
85
|
+
|
|
34
86
|
body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
|
|
35
87
|
raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?
|
|
36
88
|
|
|
@@ -38,12 +90,32 @@ module BetterAuth
|
|
|
38
90
|
updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
|
|
39
91
|
merged = session[:session].merge(updated || update)
|
|
40
92
|
Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
|
|
41
|
-
ctx.json(
|
|
93
|
+
ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
|
|
42
94
|
end
|
|
43
95
|
end
|
|
44
96
|
|
|
45
97
|
def self.revoke_session
|
|
46
|
-
Endpoint.new(
|
|
98
|
+
Endpoint.new(
|
|
99
|
+
path: "/revoke-session",
|
|
100
|
+
method: "POST",
|
|
101
|
+
metadata: {
|
|
102
|
+
openapi: {
|
|
103
|
+
operationId: "revokeSession",
|
|
104
|
+
description: "Revoke a session by token",
|
|
105
|
+
requestBody: OpenAPI.json_request_body(
|
|
106
|
+
OpenAPI.object_schema(
|
|
107
|
+
{
|
|
108
|
+
token: {type: "string", description: "The session token to revoke"}
|
|
109
|
+
},
|
|
110
|
+
required: ["token"]
|
|
111
|
+
)
|
|
112
|
+
),
|
|
113
|
+
responses: {
|
|
114
|
+
"200" => OpenAPI.json_response("Session revoked", OpenAPI.status_response_schema)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
) do |ctx|
|
|
47
119
|
session = current_session(ctx, sensitive: true)
|
|
48
120
|
body = normalize_hash(ctx.body)
|
|
49
121
|
token = body["token"].to_s
|
|
@@ -58,7 +130,19 @@ module BetterAuth
|
|
|
58
130
|
end
|
|
59
131
|
|
|
60
132
|
def self.revoke_sessions
|
|
61
|
-
Endpoint.new(
|
|
133
|
+
Endpoint.new(
|
|
134
|
+
path: "/revoke-sessions",
|
|
135
|
+
method: "POST",
|
|
136
|
+
metadata: {
|
|
137
|
+
openapi: {
|
|
138
|
+
operationId: "revokeSessions",
|
|
139
|
+
description: "Revoke all sessions for the current user",
|
|
140
|
+
responses: {
|
|
141
|
+
"200" => OpenAPI.json_response("Sessions revoked", OpenAPI.status_response_schema)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
) do |ctx|
|
|
62
146
|
session = current_session(ctx, sensitive: true)
|
|
63
147
|
ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
|
|
64
148
|
Cookies.delete_session_cookie(ctx)
|
|
@@ -67,7 +151,19 @@ module BetterAuth
|
|
|
67
151
|
end
|
|
68
152
|
|
|
69
153
|
def self.revoke_other_sessions
|
|
70
|
-
Endpoint.new(
|
|
154
|
+
Endpoint.new(
|
|
155
|
+
path: "/revoke-other-sessions",
|
|
156
|
+
method: "POST",
|
|
157
|
+
metadata: {
|
|
158
|
+
openapi: {
|
|
159
|
+
operationId: "revokeOtherSessions",
|
|
160
|
+
description: "Revoke all sessions except the current one",
|
|
161
|
+
responses: {
|
|
162
|
+
"200" => OpenAPI.json_response("Other sessions revoked", OpenAPI.status_response_schema)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
) do |ctx|
|
|
71
167
|
session = current_session(ctx, sensitive: true)
|
|
72
168
|
current_token = session[:session]["token"]
|
|
73
169
|
sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
|
|
@@ -81,11 +177,11 @@ module BetterAuth
|
|
|
81
177
|
end
|
|
82
178
|
end
|
|
83
179
|
|
|
84
|
-
def self.current_session(ctx, allow_nil: false, sensitive: false)
|
|
180
|
+
def self.current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false)
|
|
85
181
|
data = Session.find_current(
|
|
86
182
|
ctx,
|
|
87
183
|
disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
|
|
88
|
-
disable_refresh: truthy_query?(ctx.query, "disableRefresh"),
|
|
184
|
+
disable_refresh: disable_refresh || truthy_query?(ctx.query, "disableRefresh"),
|
|
89
185
|
sensitive: sensitive
|
|
90
186
|
)
|
|
91
187
|
return nil if allow_nil && data.nil?
|
|
@@ -93,7 +189,7 @@ module BetterAuth
|
|
|
93
189
|
raise APIError.new("UNAUTHORIZED") unless data
|
|
94
190
|
|
|
95
191
|
session = stringify_keys(data[:session] || data["session"])
|
|
96
|
-
ensure_fresh_session!(ctx, session) if
|
|
192
|
+
ensure_fresh_session!(ctx, session) if fresh
|
|
97
193
|
|
|
98
194
|
{
|
|
99
195
|
session: session,
|
|
@@ -101,6 +197,15 @@ module BetterAuth
|
|
|
101
197
|
}
|
|
102
198
|
end
|
|
103
199
|
|
|
200
|
+
def self.request_only_session(ctx)
|
|
201
|
+
session = current_session(ctx, allow_nil: true)
|
|
202
|
+
if !session && (ctx.request || !ctx.headers.empty?)
|
|
203
|
+
raise APIError.new("UNAUTHORIZED", code: "UNAUTHORIZED", message: "Unauthorized")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
session
|
|
207
|
+
end
|
|
208
|
+
|
|
104
209
|
def self.ensure_fresh_session!(ctx, session)
|
|
105
210
|
fresh_age = ctx.context.session_config[:fresh_age].to_i
|
|
106
211
|
return if fresh_age.zero?
|
|
@@ -111,6 +216,16 @@ module BetterAuth
|
|
|
111
216
|
raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH"))
|
|
112
217
|
end
|
|
113
218
|
|
|
219
|
+
def self.session_needs_refresh?(ctx, session)
|
|
220
|
+
return false if truthy_query?(ctx.query, "disableRefresh") || ctx.context.session_config[:disable_session_refresh]
|
|
221
|
+
|
|
222
|
+
update_age = ctx.context.session_config[:update_age].to_i
|
|
223
|
+
return true if update_age.zero?
|
|
224
|
+
|
|
225
|
+
updated_at = normalize_time(session["updatedAt"])
|
|
226
|
+
updated_at && updated_at + update_age <= Time.now
|
|
227
|
+
end
|
|
228
|
+
|
|
114
229
|
def self.normalize_time(value)
|
|
115
230
|
return value if value.is_a?(Time)
|
|
116
231
|
return nil if value.nil? || value.to_s.empty?
|
|
@@ -8,17 +8,39 @@ module BetterAuth
|
|
|
8
8
|
Endpoint.new(
|
|
9
9
|
path: "/sign-in/email",
|
|
10
10
|
method: "POST",
|
|
11
|
+
body_schema: request_body_schema(required_strings: %w[email password]),
|
|
11
12
|
metadata: {
|
|
12
13
|
allowed_media_types: [
|
|
13
14
|
"application/x-www-form-urlencoded",
|
|
14
15
|
"application/json"
|
|
15
|
-
]
|
|
16
|
+
],
|
|
17
|
+
openapi: {
|
|
18
|
+
operationId: "signInEmail",
|
|
19
|
+
description: "Sign in with email and password",
|
|
20
|
+
requestBody: OpenAPI.json_request_body(
|
|
21
|
+
OpenAPI.object_schema(
|
|
22
|
+
{
|
|
23
|
+
email: {type: "string", description: "Email of the user"},
|
|
24
|
+
password: {type: "string", description: "Password of the user"},
|
|
25
|
+
callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
|
|
26
|
+
rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
|
|
27
|
+
},
|
|
28
|
+
required: ["email", "password"]
|
|
29
|
+
)
|
|
30
|
+
),
|
|
31
|
+
responses: {
|
|
32
|
+
"200" => OpenAPI.json_response(
|
|
33
|
+
"Success - Returns either session details or redirect URL",
|
|
34
|
+
OpenAPI.session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
16
38
|
}
|
|
17
39
|
) do |ctx|
|
|
18
40
|
options = ctx.context.options
|
|
19
41
|
email_config = options.email_and_password
|
|
20
42
|
if email_config[:enabled] != true
|
|
21
|
-
raise APIError.new("BAD_REQUEST",
|
|
43
|
+
raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_DISABLED"])
|
|
22
44
|
end
|
|
23
45
|
|
|
24
46
|
body = normalize_hash(ctx.body)
|
|
@@ -27,6 +49,8 @@ module BetterAuth
|
|
|
27
49
|
callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
|
|
28
50
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
29
51
|
|
|
52
|
+
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
53
|
+
|
|
30
54
|
unless EMAIL_PATTERN.match?(email)
|
|
31
55
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
|
|
32
56
|
end
|
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
module BetterAuth
|
|
4
4
|
module Routes
|
|
5
5
|
def self.sign_out
|
|
6
|
-
Endpoint.new(
|
|
6
|
+
Endpoint.new(
|
|
7
|
+
path: "/sign-out",
|
|
8
|
+
method: "POST",
|
|
9
|
+
metadata: {
|
|
10
|
+
openapi: {
|
|
11
|
+
operationId: "signOut",
|
|
12
|
+
description: "Sign out the current session",
|
|
13
|
+
responses: {
|
|
14
|
+
"200" => OpenAPI.json_response("Successfully signed out", OpenAPI.success_response_schema)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
) do |ctx|
|
|
7
19
|
token_cookie = ctx.context.auth_cookies[:session_token]
|
|
8
20
|
token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
|
|
9
21
|
ctx.context.internal_adapter.delete_session(token) if token
|