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
|
@@ -65,7 +65,38 @@ module BetterAuth
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def sign_in_phone_number_endpoint(config)
|
|
68
|
-
Endpoint.new(
|
|
68
|
+
Endpoint.new(
|
|
69
|
+
path: "/sign-in/phone-number",
|
|
70
|
+
method: "POST",
|
|
71
|
+
metadata: {
|
|
72
|
+
openapi: {
|
|
73
|
+
operationId: "signInPhoneNumber",
|
|
74
|
+
description: "Sign in with phone number and password",
|
|
75
|
+
requestBody: OpenAPI.json_request_body(
|
|
76
|
+
OpenAPI.object_schema(
|
|
77
|
+
{
|
|
78
|
+
phoneNumber: {type: "string"},
|
|
79
|
+
password: {type: "string"},
|
|
80
|
+
rememberMe: {type: ["boolean", "null"]}
|
|
81
|
+
},
|
|
82
|
+
required: ["phoneNumber", "password"]
|
|
83
|
+
)
|
|
84
|
+
),
|
|
85
|
+
responses: {
|
|
86
|
+
"200" => OpenAPI.json_response(
|
|
87
|
+
"Signed in",
|
|
88
|
+
OpenAPI.object_schema(
|
|
89
|
+
{
|
|
90
|
+
token: {type: "string"},
|
|
91
|
+
user: {type: "object", "$ref": "#/components/schemas/User"}
|
|
92
|
+
},
|
|
93
|
+
required: ["token", "user"]
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
) do |ctx|
|
|
69
100
|
body = normalize_hash(ctx.body)
|
|
70
101
|
phone_number = body[:phone_number].to_s
|
|
71
102
|
password = body[:password].to_s
|
|
@@ -101,7 +132,35 @@ module BetterAuth
|
|
|
101
132
|
end
|
|
102
133
|
|
|
103
134
|
def send_phone_number_otp_endpoint(config)
|
|
104
|
-
Endpoint.new(
|
|
135
|
+
Endpoint.new(
|
|
136
|
+
path: "/phone-number/send-otp",
|
|
137
|
+
method: "POST",
|
|
138
|
+
metadata: {
|
|
139
|
+
openapi: {
|
|
140
|
+
operationId: "sendPhoneNumberOTP",
|
|
141
|
+
description: "Send a phone number OTP",
|
|
142
|
+
requestBody: OpenAPI.json_request_body(
|
|
143
|
+
OpenAPI.object_schema(
|
|
144
|
+
{
|
|
145
|
+
phoneNumber: {type: "string"}
|
|
146
|
+
},
|
|
147
|
+
required: ["phoneNumber"]
|
|
148
|
+
)
|
|
149
|
+
),
|
|
150
|
+
responses: {
|
|
151
|
+
"200" => OpenAPI.json_response(
|
|
152
|
+
"OTP sent",
|
|
153
|
+
OpenAPI.object_schema(
|
|
154
|
+
{
|
|
155
|
+
message: {type: "string"}
|
|
156
|
+
},
|
|
157
|
+
required: ["message"]
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
) do |ctx|
|
|
105
164
|
sender = config[:send_otp]
|
|
106
165
|
unless sender.respond_to?(:call)
|
|
107
166
|
raise APIError.new("NOT_IMPLEMENTED", message: PHONE_NUMBER_ERROR_CODES["SEND_OTP_NOT_IMPLEMENTED"])
|
|
@@ -118,7 +177,40 @@ module BetterAuth
|
|
|
118
177
|
end
|
|
119
178
|
|
|
120
179
|
def verify_phone_number_endpoint(config)
|
|
121
|
-
Endpoint.new(
|
|
180
|
+
Endpoint.new(
|
|
181
|
+
path: "/phone-number/verify",
|
|
182
|
+
method: "POST",
|
|
183
|
+
metadata: {
|
|
184
|
+
openapi: {
|
|
185
|
+
operationId: "verifyPhoneNumber",
|
|
186
|
+
description: "Verify a phone number OTP",
|
|
187
|
+
requestBody: OpenAPI.json_request_body(
|
|
188
|
+
OpenAPI.object_schema(
|
|
189
|
+
{
|
|
190
|
+
phoneNumber: {type: "string"},
|
|
191
|
+
code: {type: "string"},
|
|
192
|
+
updatePhoneNumber: {type: ["boolean", "null"]},
|
|
193
|
+
disableSession: {type: ["boolean", "null"]}
|
|
194
|
+
},
|
|
195
|
+
required: ["phoneNumber", "code"]
|
|
196
|
+
)
|
|
197
|
+
),
|
|
198
|
+
responses: {
|
|
199
|
+
"200" => OpenAPI.json_response(
|
|
200
|
+
"Phone number verified",
|
|
201
|
+
OpenAPI.object_schema(
|
|
202
|
+
{
|
|
203
|
+
status: {type: "boolean"},
|
|
204
|
+
token: {type: ["string", "null"]},
|
|
205
|
+
user: {type: "object", "$ref": "#/components/schemas/User"}
|
|
206
|
+
},
|
|
207
|
+
required: ["status", "user"]
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
) do |ctx|
|
|
122
214
|
body = normalize_hash(ctx.body)
|
|
123
215
|
phone_number = body[:phone_number].to_s
|
|
124
216
|
code = body[:code].to_s
|
|
@@ -163,7 +255,27 @@ module BetterAuth
|
|
|
163
255
|
end
|
|
164
256
|
|
|
165
257
|
def request_password_reset_phone_number_endpoint(config)
|
|
166
|
-
Endpoint.new(
|
|
258
|
+
Endpoint.new(
|
|
259
|
+
path: "/phone-number/request-password-reset",
|
|
260
|
+
method: "POST",
|
|
261
|
+
metadata: {
|
|
262
|
+
openapi: {
|
|
263
|
+
operationId: "requestPasswordResetPhoneNumber",
|
|
264
|
+
description: "Request a phone number password reset OTP",
|
|
265
|
+
requestBody: OpenAPI.json_request_body(
|
|
266
|
+
OpenAPI.object_schema(
|
|
267
|
+
{
|
|
268
|
+
phoneNumber: {type: "string"}
|
|
269
|
+
},
|
|
270
|
+
required: ["phoneNumber"]
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
responses: {
|
|
274
|
+
"200" => OpenAPI.json_response("Password reset OTP requested", OpenAPI.status_response_schema)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
) do |ctx|
|
|
167
279
|
body = normalize_hash(ctx.body)
|
|
168
280
|
phone_number = body[:phone_number].to_s
|
|
169
281
|
user = ctx.context.adapter.find_one(model: "user", where: [{field: "phoneNumber", value: phone_number}])
|
|
@@ -179,7 +291,29 @@ module BetterAuth
|
|
|
179
291
|
end
|
|
180
292
|
|
|
181
293
|
def reset_password_phone_number_endpoint(config)
|
|
182
|
-
Endpoint.new(
|
|
294
|
+
Endpoint.new(
|
|
295
|
+
path: "/phone-number/reset-password",
|
|
296
|
+
method: "POST",
|
|
297
|
+
metadata: {
|
|
298
|
+
openapi: {
|
|
299
|
+
operationId: "resetPasswordPhoneNumber",
|
|
300
|
+
description: "Reset a password with a phone number OTP",
|
|
301
|
+
requestBody: OpenAPI.json_request_body(
|
|
302
|
+
OpenAPI.object_schema(
|
|
303
|
+
{
|
|
304
|
+
phoneNumber: {type: "string"},
|
|
305
|
+
otp: {type: "string"},
|
|
306
|
+
newPassword: {type: "string"}
|
|
307
|
+
},
|
|
308
|
+
required: ["phoneNumber", "otp", "newPassword"]
|
|
309
|
+
)
|
|
310
|
+
),
|
|
311
|
+
responses: {
|
|
312
|
+
"200" => OpenAPI.json_response("Password reset", OpenAPI.status_response_schema)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
) do |ctx|
|
|
183
317
|
body = normalize_hash(ctx.body)
|
|
184
318
|
phone_number = body[:phone_number].to_s
|
|
185
319
|
otp = body[:otp].to_s
|
|
@@ -225,7 +359,8 @@ module BetterAuth
|
|
|
225
359
|
"phoneNumber" => phone_number,
|
|
226
360
|
"phoneNumberVerified" => true,
|
|
227
361
|
"emailVerified" => false
|
|
228
|
-
)
|
|
362
|
+
),
|
|
363
|
+
context: ctx
|
|
229
364
|
)
|
|
230
365
|
end
|
|
231
366
|
|
|
@@ -24,7 +24,37 @@ module BetterAuth
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def get_siwe_nonce_endpoint(config)
|
|
27
|
-
Endpoint.new(
|
|
27
|
+
Endpoint.new(
|
|
28
|
+
path: "/siwe/nonce",
|
|
29
|
+
method: "POST",
|
|
30
|
+
body_schema: ->(body) { siwe_nonce_body(body) },
|
|
31
|
+
metadata: {
|
|
32
|
+
openapi: {
|
|
33
|
+
operationId: "getSiweNonce",
|
|
34
|
+
description: "Generate a nonce for Sign-In with Ethereum",
|
|
35
|
+
requestBody: OpenAPI.json_request_body(
|
|
36
|
+
OpenAPI.object_schema(
|
|
37
|
+
{
|
|
38
|
+
walletAddress: {type: "string"},
|
|
39
|
+
chainId: {type: ["number", "string", "null"]}
|
|
40
|
+
},
|
|
41
|
+
required: ["walletAddress"]
|
|
42
|
+
)
|
|
43
|
+
),
|
|
44
|
+
responses: {
|
|
45
|
+
"200" => OpenAPI.json_response(
|
|
46
|
+
"SIWE nonce",
|
|
47
|
+
OpenAPI.object_schema(
|
|
48
|
+
{
|
|
49
|
+
nonce: {type: "string"}
|
|
50
|
+
},
|
|
51
|
+
required: ["nonce"]
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
) do |ctx|
|
|
28
58
|
body = normalize_hash(ctx.body)
|
|
29
59
|
wallet_address = siwe_normalize_wallet!(body[:wallet_address])
|
|
30
60
|
chain_id = siwe_chain_id(body[:chain_id])
|
|
@@ -42,7 +72,42 @@ module BetterAuth
|
|
|
42
72
|
end
|
|
43
73
|
|
|
44
74
|
def verify_siwe_message_endpoint(config)
|
|
45
|
-
Endpoint.new(
|
|
75
|
+
Endpoint.new(
|
|
76
|
+
path: "/siwe/verify",
|
|
77
|
+
method: "POST",
|
|
78
|
+
body_schema: ->(body) { siwe_verify_body(body, config) },
|
|
79
|
+
metadata: {
|
|
80
|
+
openapi: {
|
|
81
|
+
operationId: "verifySiweMessage",
|
|
82
|
+
description: "Verify a Sign-In with Ethereum message",
|
|
83
|
+
requestBody: OpenAPI.json_request_body(
|
|
84
|
+
OpenAPI.object_schema(
|
|
85
|
+
{
|
|
86
|
+
walletAddress: {type: "string"},
|
|
87
|
+
chainId: {type: ["number", "string", "null"]},
|
|
88
|
+
message: {type: "string"},
|
|
89
|
+
signature: {type: "string"},
|
|
90
|
+
email: {type: ["string", "null"]}
|
|
91
|
+
},
|
|
92
|
+
required: ["walletAddress", "message", "signature"]
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
responses: {
|
|
96
|
+
"200" => OpenAPI.json_response(
|
|
97
|
+
"SIWE message verified",
|
|
98
|
+
OpenAPI.object_schema(
|
|
99
|
+
{
|
|
100
|
+
token: {type: "string"},
|
|
101
|
+
success: {type: "boolean"},
|
|
102
|
+
user: {type: "object"}
|
|
103
|
+
},
|
|
104
|
+
required: ["token", "success", "user"]
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
) do |ctx|
|
|
46
111
|
body = normalize_hash(ctx.body)
|
|
47
112
|
wallet_address = siwe_normalize_wallet!(body[:wallet_address])
|
|
48
113
|
chain_id = siwe_chain_id(body[:chain_id])
|
|
@@ -203,7 +268,8 @@ module BetterAuth
|
|
|
203
268
|
ctx.context.internal_adapter.create_user(
|
|
204
269
|
name: ens[:name] || wallet_address,
|
|
205
270
|
email: (anonymous == false && !email.empty?) ? email : "#{wallet_address}@#{domain}",
|
|
206
|
-
image: ens[:avatar] || ""
|
|
271
|
+
image: ens[:avatar] || "",
|
|
272
|
+
context: ctx
|
|
207
273
|
)
|
|
208
274
|
end
|
|
209
275
|
|
|
@@ -24,6 +24,7 @@ module BetterAuth
|
|
|
24
24
|
TRUST_DEVICE_COOKIE_NAME = "trust_device"
|
|
25
25
|
TRUST_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 60 * 60
|
|
26
26
|
TWO_FACTOR_COOKIE_MAX_AGE = 10 * 60
|
|
27
|
+
TWO_FACTOR_MODEL = "twoFactor"
|
|
27
28
|
|
|
28
29
|
module_function
|
|
29
30
|
|
|
@@ -39,6 +40,8 @@ module BetterAuth
|
|
|
39
40
|
config[:backup_code_options] = {store_backup_codes: "encrypted"}.merge(normalize_hash(config[:backup_code_options]))
|
|
40
41
|
config[:otp_options] = normalize_hash(config[:otp_options])
|
|
41
42
|
config[:totp_options] = normalize_hash(config[:totp_options])
|
|
43
|
+
config[:backup_code_options][:allow_passwordless] = config[:allow_passwordless] unless config[:backup_code_options].key?(:allow_passwordless)
|
|
44
|
+
config[:totp_options][:allow_passwordless] = config[:allow_passwordless] unless config[:totp_options].key?(:allow_passwordless)
|
|
42
45
|
|
|
43
46
|
Plugin.new(
|
|
44
47
|
id: "two-factor",
|
|
@@ -62,7 +65,7 @@ module BetterAuth
|
|
|
62
65
|
}
|
|
63
66
|
]
|
|
64
67
|
},
|
|
65
|
-
schema: two_factor_schema(config
|
|
68
|
+
schema: two_factor_schema(config),
|
|
66
69
|
rate_limit: [
|
|
67
70
|
{
|
|
68
71
|
path_matcher: ->(path) { path.start_with?("/two-factor/") },
|
|
@@ -76,13 +79,13 @@ module BetterAuth
|
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
def two_factor_enable_endpoint(config)
|
|
79
|
-
Endpoint.new(path: "/two-factor/enable", method: "POST") do |ctx|
|
|
80
|
-
session = Routes.current_session(ctx
|
|
82
|
+
Endpoint.new(path: "/two-factor/enable", method: "POST", metadata: two_factor_openapi("enableTwoFactor", "Enable two factor authentication", two_factor_enable_response_schema)) do |ctx|
|
|
83
|
+
session = Routes.current_session(ctx)
|
|
81
84
|
body = normalize_hash(ctx.body)
|
|
82
|
-
two_factor_check_password!(ctx, session[:user]["id"], body[:password])
|
|
85
|
+
two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
|
|
83
86
|
|
|
84
87
|
secret = two_factor_generate_secret
|
|
85
|
-
backup = two_factor_generate_backup_codes(ctx.context.
|
|
88
|
+
backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
|
|
86
89
|
if config[:skip_verification_on_enable]
|
|
87
90
|
updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: true)
|
|
88
91
|
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
@@ -90,14 +93,18 @@ module BetterAuth
|
|
|
90
93
|
ctx.context.internal_adapter.delete_session(session[:session]["token"])
|
|
91
94
|
end
|
|
92
95
|
|
|
93
|
-
ctx
|
|
96
|
+
existing = two_factor_record(ctx, config, session[:user]["id"])
|
|
97
|
+
verified = (!!existing && existing["verified"] != false) || !!config[:skip_verification_on_enable]
|
|
98
|
+
ctx.context.adapter.delete_many(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: session[:user]["id"]}])
|
|
94
99
|
ctx.context.adapter.create(
|
|
95
|
-
model:
|
|
100
|
+
model: TWO_FACTOR_MODEL,
|
|
96
101
|
data: {
|
|
97
|
-
secret: Crypto.symmetric_encrypt(key: ctx.context.
|
|
102
|
+
secret: Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: secret),
|
|
98
103
|
backupCodes: backup[:stored],
|
|
99
|
-
userId: session[:user]["id"]
|
|
100
|
-
|
|
104
|
+
userId: session[:user]["id"],
|
|
105
|
+
verified: verified
|
|
106
|
+
},
|
|
107
|
+
force_allow_id: true
|
|
101
108
|
)
|
|
102
109
|
|
|
103
110
|
ctx.json({
|
|
@@ -108,13 +115,13 @@ module BetterAuth
|
|
|
108
115
|
end
|
|
109
116
|
|
|
110
117
|
def two_factor_disable_endpoint(config)
|
|
111
|
-
Endpoint.new(path: "/two-factor/disable", method: "POST") do |ctx|
|
|
112
|
-
session = Routes.current_session(ctx
|
|
118
|
+
Endpoint.new(path: "/two-factor/disable", method: "POST", metadata: two_factor_openapi("disableTwoFactor", "Disable two factor authentication", OpenAPI.status_response_schema)) do |ctx|
|
|
119
|
+
session = Routes.current_session(ctx)
|
|
113
120
|
body = normalize_hash(ctx.body)
|
|
114
|
-
two_factor_check_password!(ctx, session[:user]["id"], body[:password])
|
|
121
|
+
two_factor_check_password!(ctx, session[:user]["id"], body[:password], allow_passwordless: config[:allow_passwordless])
|
|
115
122
|
|
|
116
123
|
updated_user = ctx.context.internal_adapter.update_user(session[:user]["id"], twoFactorEnabled: false)
|
|
117
|
-
ctx.context.adapter.delete(model:
|
|
124
|
+
ctx.context.adapter.delete(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: updated_user["id"]}])
|
|
118
125
|
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
119
126
|
Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
|
|
120
127
|
ctx.context.internal_adapter.delete_session(session[:session]["token"])
|
|
@@ -131,7 +138,7 @@ module BetterAuth
|
|
|
131
138
|
end
|
|
132
139
|
|
|
133
140
|
def two_factor_generate_totp_endpoint(config)
|
|
134
|
-
Endpoint.new(path: "/totp/generate", method: "POST") do |ctx|
|
|
141
|
+
Endpoint.new(path: "/totp/generate", method: "POST", metadata: two_factor_openapi("generateTOTP", "Generate a TOTP code", OpenAPI.object_schema({code: {type: "string"}}, required: ["code"]))) do |ctx|
|
|
135
142
|
two_factor_totp_enabled!(config)
|
|
136
143
|
body = normalize_hash(ctx.body)
|
|
137
144
|
ctx.json({code: two_factor_totp(body[:secret], options: config[:totp_options])})
|
|
@@ -139,30 +146,41 @@ module BetterAuth
|
|
|
139
146
|
end
|
|
140
147
|
|
|
141
148
|
def two_factor_get_totp_uri_endpoint(config)
|
|
142
|
-
Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST") do |ctx|
|
|
149
|
+
Endpoint.new(path: "/two-factor/get-totp-uri", method: "POST", metadata: two_factor_openapi("getTOTPURI", "Get the TOTP URI", OpenAPI.object_schema({totpURI: {type: "string"}}, required: ["totpURI"]))) do |ctx|
|
|
143
150
|
two_factor_totp_enabled!(config)
|
|
144
|
-
session = Routes.current_session(ctx
|
|
145
|
-
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
|
|
151
|
+
session = Routes.current_session(ctx)
|
|
152
|
+
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:totp_options][:allow_passwordless])
|
|
146
153
|
record = two_factor_record(ctx, config, session[:user]["id"])
|
|
147
154
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
|
|
148
155
|
|
|
149
|
-
secret = Crypto.symmetric_decrypt(key: ctx.context.
|
|
156
|
+
secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
|
|
150
157
|
ctx.json({totpURI: two_factor_totp_uri(secret, issuer: config[:issuer] || ctx.context.app_name, account: session[:user]["email"], options: config[:totp_options])})
|
|
151
158
|
end
|
|
152
159
|
end
|
|
153
160
|
|
|
154
161
|
def two_factor_verify_totp_endpoint(config)
|
|
155
|
-
Endpoint.new(path: "/two-factor/verify-totp", method: "POST") do |ctx|
|
|
162
|
+
Endpoint.new(path: "/two-factor/verify-totp", method: "POST", metadata: two_factor_openapi("verifyTOTP", "Verify a TOTP code", two_factor_verification_response_schema)) do |ctx|
|
|
156
163
|
two_factor_totp_enabled!(config)
|
|
157
164
|
body = normalize_hash(ctx.body)
|
|
158
165
|
data = two_factor_verification_context(ctx, config)
|
|
159
166
|
record = two_factor_record(ctx, config, data[:session][:user]["id"])
|
|
160
167
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"]) unless record
|
|
168
|
+
if !data[:session][:session] && record["verified"] == false
|
|
169
|
+
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TOTP_NOT_ENABLED"])
|
|
170
|
+
end
|
|
161
171
|
|
|
162
|
-
secret = Crypto.symmetric_decrypt(key: ctx.context.
|
|
172
|
+
secret = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: record["secret"])
|
|
163
173
|
raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_CODE"]) unless two_factor_totp_valid?(secret, body[:code], options: config[:totp_options])
|
|
164
174
|
|
|
165
|
-
if
|
|
175
|
+
if record["verified"] != true
|
|
176
|
+
if !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
|
|
177
|
+
updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
|
|
178
|
+
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
179
|
+
ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
|
|
180
|
+
Cookies.set_session_cookie(ctx, {session: new_session, user: updated_user})
|
|
181
|
+
end
|
|
182
|
+
ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {verified: true})
|
|
183
|
+
elsif !data[:session][:user]["twoFactorEnabled"] && data[:session][:session]
|
|
166
184
|
updated_user = ctx.context.internal_adapter.update_user(data[:session][:user]["id"], twoFactorEnabled: true)
|
|
167
185
|
new_session = ctx.context.internal_adapter.create_session(updated_user["id"], false)
|
|
168
186
|
ctx.context.internal_adapter.delete_session(data[:session][:session]["token"])
|
|
@@ -173,7 +191,7 @@ module BetterAuth
|
|
|
173
191
|
end
|
|
174
192
|
|
|
175
193
|
def two_factor_send_otp_endpoint(config)
|
|
176
|
-
Endpoint.new(path: "/two-factor/send-otp", method: "POST") do |ctx|
|
|
194
|
+
Endpoint.new(path: "/two-factor/send-otp", method: "POST", metadata: two_factor_openapi("sendTwoFactorOTP", "Send a two factor OTP", OpenAPI.status_response_schema)) do |ctx|
|
|
177
195
|
otp_config = config[:otp_options]
|
|
178
196
|
sender = otp_config[:send_otp]
|
|
179
197
|
unless sender.respond_to?(:call)
|
|
@@ -194,7 +212,7 @@ module BetterAuth
|
|
|
194
212
|
end
|
|
195
213
|
|
|
196
214
|
def two_factor_verify_otp_endpoint(config)
|
|
197
|
-
Endpoint.new(path: "/two-factor/verify-otp", method: "POST") do |ctx|
|
|
215
|
+
Endpoint.new(path: "/two-factor/verify-otp", method: "POST", metadata: two_factor_openapi("verifyTwoFactorOTP", "Verify a two factor OTP", two_factor_verification_response_schema)) do |ctx|
|
|
198
216
|
body = normalize_hash(ctx.body)
|
|
199
217
|
data = two_factor_verification_context(ctx, config)
|
|
200
218
|
verification = ctx.context.internal_adapter.find_verification_value("2fa-otp-#{data[:key]}")
|
|
@@ -228,21 +246,21 @@ module BetterAuth
|
|
|
228
246
|
end
|
|
229
247
|
|
|
230
248
|
def two_factor_verify_backup_code_endpoint(config)
|
|
231
|
-
Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST") do |ctx|
|
|
249
|
+
Endpoint.new(path: "/two-factor/verify-backup-code", method: "POST", metadata: two_factor_openapi("verifyBackupCode", "Verify a two factor backup code", two_factor_verification_response_schema)) do |ctx|
|
|
232
250
|
body = normalize_hash(ctx.body)
|
|
233
251
|
data = two_factor_verification_context(ctx, config)
|
|
234
252
|
record = two_factor_record(ctx, config, data[:session][:user]["id"])
|
|
235
253
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
|
|
236
254
|
|
|
237
|
-
codes = two_factor_read_backup_codes(ctx.context.
|
|
255
|
+
codes = two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])
|
|
238
256
|
unless codes.include?(body[:code].to_s)
|
|
239
257
|
raise APIError.new("UNAUTHORIZED", message: TWO_FACTOR_ERROR_CODES["INVALID_BACKUP_CODE"])
|
|
240
258
|
end
|
|
241
259
|
|
|
242
260
|
remaining = codes.reject { |code| code == body[:code].to_s }
|
|
243
|
-
stored = two_factor_store_backup_codes(ctx.context.
|
|
261
|
+
stored = two_factor_store_backup_codes(ctx.context.secret_config, remaining, config[:backup_code_options])
|
|
244
262
|
updated = ctx.context.adapter.update(
|
|
245
|
-
model:
|
|
263
|
+
model: TWO_FACTOR_MODEL,
|
|
246
264
|
where: [{field: "id", value: record["id"]}, {field: "backupCodes", value: record["backupCodes"]}],
|
|
247
265
|
update: {backupCodes: stored}
|
|
248
266
|
)
|
|
@@ -253,16 +271,16 @@ module BetterAuth
|
|
|
253
271
|
end
|
|
254
272
|
|
|
255
273
|
def two_factor_generate_backup_codes_endpoint(config)
|
|
256
|
-
Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST") do |ctx|
|
|
257
|
-
session = Routes.current_session(ctx
|
|
274
|
+
Endpoint.new(path: "/two-factor/generate-backup-codes", method: "POST", metadata: two_factor_openapi("generateBackupCodes", "Generate two factor backup codes", two_factor_backup_codes_response_schema)) do |ctx|
|
|
275
|
+
session = Routes.current_session(ctx)
|
|
258
276
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless session[:user]["twoFactorEnabled"]
|
|
259
277
|
|
|
260
|
-
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password])
|
|
278
|
+
two_factor_check_password!(ctx, session[:user]["id"], normalize_hash(ctx.body)[:password], allow_passwordless: config[:backup_code_options][:allow_passwordless])
|
|
261
279
|
record = two_factor_record(ctx, config, session[:user]["id"])
|
|
262
280
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["TWO_FACTOR_NOT_ENABLED"]) unless record
|
|
263
281
|
|
|
264
|
-
backup = two_factor_generate_backup_codes(ctx.context.
|
|
265
|
-
ctx.context.adapter.update(model:
|
|
282
|
+
backup = two_factor_generate_backup_codes(ctx.context.secret_config, config[:backup_code_options])
|
|
283
|
+
ctx.context.adapter.update(model: TWO_FACTOR_MODEL, where: [{field: "id", value: record["id"]}], update: {backupCodes: backup[:stored]})
|
|
266
284
|
ctx.json({status: true, backupCodes: backup[:codes]})
|
|
267
285
|
end
|
|
268
286
|
end
|
|
@@ -273,11 +291,54 @@ module BetterAuth
|
|
|
273
291
|
record = two_factor_record(ctx, config, body[:user_id])
|
|
274
292
|
raise APIError.new("BAD_REQUEST", message: TWO_FACTOR_ERROR_CODES["BACKUP_CODES_NOT_ENABLED"]) unless record
|
|
275
293
|
|
|
276
|
-
ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.
|
|
294
|
+
ctx.json({status: true, backupCodes: two_factor_read_backup_codes(ctx.context.secret_config, record["backupCodes"], config[:backup_code_options])})
|
|
277
295
|
end
|
|
278
296
|
end
|
|
279
297
|
|
|
280
|
-
def
|
|
298
|
+
def two_factor_openapi(operation_id, description, response_schema)
|
|
299
|
+
{
|
|
300
|
+
openapi: {
|
|
301
|
+
operationId: operation_id,
|
|
302
|
+
description: description,
|
|
303
|
+
responses: {
|
|
304
|
+
"200" => OpenAPI.json_response("Success", response_schema)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def two_factor_enable_response_schema
|
|
311
|
+
OpenAPI.object_schema(
|
|
312
|
+
{
|
|
313
|
+
totpURI: {type: "string"},
|
|
314
|
+
backupCodes: {type: "array", items: {type: "string"}}
|
|
315
|
+
},
|
|
316
|
+
required: ["totpURI", "backupCodes"]
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def two_factor_verification_response_schema
|
|
321
|
+
OpenAPI.object_schema(
|
|
322
|
+
{
|
|
323
|
+
token: {type: ["string", "null"]},
|
|
324
|
+
user: {type: ["object", "null"], "$ref": "#/components/schemas/User"},
|
|
325
|
+
status: {type: ["boolean", "null"]}
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def two_factor_backup_codes_response_schema
|
|
331
|
+
OpenAPI.object_schema(
|
|
332
|
+
{
|
|
333
|
+
status: {type: "boolean"},
|
|
334
|
+
backupCodes: {type: "array", items: {type: "string"}}
|
|
335
|
+
},
|
|
336
|
+
required: ["status", "backupCodes"]
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def two_factor_schema(config = {})
|
|
341
|
+
custom_schema = config[:schema]
|
|
281
342
|
base = {
|
|
282
343
|
user: {
|
|
283
344
|
fields: {
|
|
@@ -288,10 +349,14 @@ module BetterAuth
|
|
|
288
349
|
fields: {
|
|
289
350
|
secret: {type: "string", required: true, returned: false, index: true},
|
|
290
351
|
backupCodes: {type: "string", required: true, returned: false},
|
|
291
|
-
userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}}
|
|
352
|
+
userId: {type: "string", required: true, returned: false, index: true, references: {model: "user", field: "id"}},
|
|
353
|
+
verified: {type: "boolean", required: false, default_value: true, input: false}
|
|
292
354
|
}
|
|
293
355
|
}
|
|
294
356
|
}
|
|
357
|
+
if config[:two_factor_table] && config[:two_factor_table] != TWO_FACTOR_MODEL
|
|
358
|
+
base[:twoFactor][:model_name] = config[:two_factor_table].to_s
|
|
359
|
+
end
|
|
295
360
|
deep_merge_hashes(base, normalize_hash(custom_schema || {}))
|
|
296
361
|
end
|
|
297
362
|
|
|
@@ -311,7 +376,7 @@ module BetterAuth
|
|
|
311
376
|
expiresAt: Time.now + config[:two_factor_cookie_max_age].to_i
|
|
312
377
|
)
|
|
313
378
|
ctx.set_signed_cookie(cookie.name, identifier, ctx.context.secret, cookie.attributes)
|
|
314
|
-
ctx.json({twoFactorRedirect: true})
|
|
379
|
+
ctx.json({twoFactorRedirect: true, twoFactorMethods: two_factor_methods(ctx, config, data[:user]["id"])})
|
|
315
380
|
end
|
|
316
381
|
|
|
317
382
|
def two_factor_verification_context(ctx, config)
|
|
@@ -377,11 +442,23 @@ module BetterAuth
|
|
|
377
442
|
end
|
|
378
443
|
|
|
379
444
|
def two_factor_record(ctx, config, user_id)
|
|
380
|
-
ctx.context.adapter.find_one(model:
|
|
445
|
+
ctx.context.adapter.find_one(model: TWO_FACTOR_MODEL, where: [{field: "userId", value: user_id}])
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def two_factor_methods(ctx, config, user_id)
|
|
449
|
+
methods = []
|
|
450
|
+
unless config[:totp_options][:disable]
|
|
451
|
+
record = two_factor_record(ctx, config, user_id)
|
|
452
|
+
methods << "totp" if record && record["verified"] != false
|
|
453
|
+
end
|
|
454
|
+
methods << "otp" if config[:otp_options][:send_otp].respond_to?(:call)
|
|
455
|
+
methods
|
|
381
456
|
end
|
|
382
457
|
|
|
383
|
-
def two_factor_check_password!(ctx, user_id, password)
|
|
458
|
+
def two_factor_check_password!(ctx, user_id, password, allow_passwordless: false)
|
|
384
459
|
account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
|
|
460
|
+
return if allow_passwordless && !account
|
|
461
|
+
|
|
385
462
|
unless account && account["password"] && Routes.verify_password_value(ctx, password.to_s, account["password"])
|
|
386
463
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
|
|
387
464
|
end
|
|
@@ -472,7 +549,7 @@ module BetterAuth
|
|
|
472
549
|
if storage == "hashed"
|
|
473
550
|
Crypto.sha256(code, encoding: :base64url)
|
|
474
551
|
elsif storage == "encrypted"
|
|
475
|
-
Crypto.symmetric_encrypt(key: ctx.context.
|
|
552
|
+
Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: code)
|
|
476
553
|
elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
|
|
477
554
|
storage[:hash].call(code)
|
|
478
555
|
elsif storage.is_a?(Hash) && storage[:encrypt].respond_to?(:call)
|
|
@@ -487,7 +564,7 @@ module BetterAuth
|
|
|
487
564
|
expected, actual = if storage == "hashed"
|
|
488
565
|
[stored, Crypto.sha256(input, encoding: :base64url)]
|
|
489
566
|
elsif storage == "encrypted"
|
|
490
|
-
[Crypto.symmetric_decrypt(key: ctx.context.
|
|
567
|
+
[Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored), input]
|
|
491
568
|
elsif storage.is_a?(Hash) && storage[:hash].respond_to?(:call)
|
|
492
569
|
[stored, storage[:hash].call(input)]
|
|
493
570
|
elsif storage.is_a?(Hash) && storage[:decrypt].respond_to?(:call)
|