@supacloud/lite 0.31.2 → 0.32.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.
- package/CHANGELOG.md +23 -0
- package/dist/cli.js +328 -168
- package/dist/index.js +328 -168
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/auth/oauth.d.ts.map +1 -1
- package/dist/runtime/storage/image-transform.d.ts.map +1 -1
- package/package.json +13 -13
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { dirname as dirname8, join as join10, resolve as resolve6 } from "path";
|
|
|
8
8
|
// package.json
|
|
9
9
|
var package_default = {
|
|
10
10
|
name: "@supacloud/lite",
|
|
11
|
-
version: "0.
|
|
11
|
+
version: "0.32.0",
|
|
12
12
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
|
|
13
13
|
type: "module",
|
|
14
14
|
license: "Apache-2.0",
|
|
@@ -67,16 +67,16 @@ var package_default = {
|
|
|
67
67
|
"@electric-sql/pglite-pgmq": "0.0.8",
|
|
68
68
|
"@electric-sql/pglite-pgvector": "0.0.11",
|
|
69
69
|
"@electric-sql/pglite-postgis": "0.2.10",
|
|
70
|
-
"@supacloud/db": "0.
|
|
70
|
+
"@supacloud/db": "0.19.0",
|
|
71
71
|
"exact-mirror": "1.2.6",
|
|
72
72
|
tar: "^7.5.22",
|
|
73
73
|
typebox: "1.3.37"
|
|
74
74
|
},
|
|
75
75
|
devDependencies: {
|
|
76
|
-
"@supacloud/app": "0.
|
|
77
|
-
"@supacloud/contracts": "0.
|
|
78
|
-
"@supacloud/compiler": "0.
|
|
79
|
-
"@supacloud/elysia": "0.
|
|
76
|
+
"@supacloud/app": "0.29.0",
|
|
77
|
+
"@supacloud/contracts": "0.12.0",
|
|
78
|
+
"@supacloud/compiler": "0.48.0",
|
|
79
|
+
"@supacloud/elysia": "0.34.0",
|
|
80
80
|
"@supabase/supabase-js": "^2.117.3",
|
|
81
81
|
"@types/bun": "^1.4.3",
|
|
82
82
|
effect: "4.0.5",
|
|
@@ -85,13 +85,13 @@ var package_default = {
|
|
|
85
85
|
typescript: "^7.0.2"
|
|
86
86
|
},
|
|
87
87
|
overrides: {
|
|
88
|
-
"@supacloud/db": "0.
|
|
89
|
-
"@supacloud/app": "0.
|
|
90
|
-
"@supacloud/compiler": "0.
|
|
91
|
-
"@supacloud/elysia": "0.
|
|
92
|
-
"@supacloud/contracts": "0.
|
|
93
|
-
"@supacloud/commands": "0.
|
|
94
|
-
"@supacloud/delivery": "0.
|
|
88
|
+
"@supacloud/db": "0.19.0",
|
|
89
|
+
"@supacloud/app": "0.29.0",
|
|
90
|
+
"@supacloud/compiler": "0.48.0",
|
|
91
|
+
"@supacloud/elysia": "0.34.0",
|
|
92
|
+
"@supacloud/contracts": "0.12.0",
|
|
93
|
+
"@supacloud/commands": "0.10.0",
|
|
94
|
+
"@supacloud/delivery": "0.20.0",
|
|
95
95
|
"@graphql-tools/utils": "^12.0.4"
|
|
96
96
|
},
|
|
97
97
|
engines: {
|
|
@@ -367,9 +367,8 @@ class OAuthService {
|
|
|
367
367
|
try {
|
|
368
368
|
const token = await this.exchangeCode(provider, code);
|
|
369
369
|
profile = await this.fetchProfile(provider, token);
|
|
370
|
-
} catch
|
|
371
|
-
|
|
372
|
-
return redirect(`${flow.redirect_to}#error=server_error&error_description=${msg}`);
|
|
370
|
+
} catch {
|
|
371
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth provider request failed")}`);
|
|
373
372
|
}
|
|
374
373
|
let userId;
|
|
375
374
|
try {
|
|
@@ -378,7 +377,7 @@ class OAuthService {
|
|
|
378
377
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
379
378
|
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
380
379
|
}
|
|
381
|
-
|
|
380
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
|
|
382
381
|
}
|
|
383
382
|
if (flow.code_challenge) {
|
|
384
383
|
const authCode = randomToken(24);
|
|
@@ -393,13 +392,13 @@ class OAuthService {
|
|
|
393
392
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
394
393
|
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
395
394
|
}
|
|
396
|
-
|
|
395
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
|
|
397
396
|
}
|
|
398
397
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
|
|
399
398
|
return redirect(`${flow.redirect_to}${hash}`);
|
|
400
399
|
}
|
|
401
400
|
async exchangePkce(authCode, verifier) {
|
|
402
|
-
const res = await this.db.query(`
|
|
401
|
+
const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
|
|
403
402
|
const flow = res.rows[0];
|
|
404
403
|
if (!flow || !flow.user_id)
|
|
405
404
|
return null;
|
|
@@ -411,7 +410,11 @@ class OAuthService {
|
|
|
411
410
|
} else if (flow.code_challenge && flow.code_challenge !== verifier) {
|
|
412
411
|
return null;
|
|
413
412
|
}
|
|
414
|
-
|
|
413
|
+
const claimed = await this.db.transaction(async (query) => {
|
|
414
|
+
const consumed = await query(`delete from auth.flow_state where auth_code = $1 and expires_at > now() returning *`, [authCode]);
|
|
415
|
+
return consumed.rows[0] ?? null;
|
|
416
|
+
});
|
|
417
|
+
return claimed?.user_id ?? null;
|
|
415
418
|
}
|
|
416
419
|
async exchangeCode(provider, code) {
|
|
417
420
|
const body = new URLSearchParams({
|
|
@@ -460,39 +463,65 @@ class OAuthService {
|
|
|
460
463
|
return existing.rows[0].user_id;
|
|
461
464
|
}
|
|
462
465
|
let userId;
|
|
463
|
-
const email = profile.email?.toLowerCase() ?? null;
|
|
466
|
+
const email = profile.email?.toLowerCase().trim() ?? null;
|
|
464
467
|
const verified = !!profile.email && profile.emailVerified === true;
|
|
465
468
|
if (email && verified) {
|
|
466
|
-
const byEmail = await query(`select id from auth.users where email = $1`, [email]);
|
|
469
|
+
const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
|
|
467
470
|
userId = byEmail.rows[0]?.id;
|
|
468
471
|
if (userId)
|
|
469
472
|
await this.assertUserEligible(query, userId);
|
|
470
473
|
}
|
|
474
|
+
let createdUserId;
|
|
471
475
|
if (!userId) {
|
|
472
476
|
let userEmail = verified ? email : null;
|
|
473
477
|
if (userEmail) {
|
|
474
|
-
const taken = await query(`select 1 from auth.users where email = $1`, [userEmail]);
|
|
478
|
+
const taken = await query(`select 1 from auth.users where lower(email) = $1`, [userEmail]);
|
|
475
479
|
if (taken.rows.length > 0)
|
|
476
480
|
userEmail = null;
|
|
477
481
|
}
|
|
478
482
|
const created = await query(`insert into auth.users (aud, role, email, email_confirmed_at, last_sign_in_at, raw_app_meta_data, raw_user_meta_data)
|
|
479
483
|
values ('authenticated','authenticated',$1, $2, now(),
|
|
480
|
-
$3::jsonb, $4::jsonb)
|
|
484
|
+
$3::jsonb, $4::jsonb)
|
|
485
|
+
on conflict (email) do nothing
|
|
486
|
+
returning id`, [
|
|
481
487
|
userEmail,
|
|
482
488
|
userEmail ? new Date().toISOString() : null,
|
|
483
489
|
JSON.stringify({ provider, providers: [provider] }),
|
|
484
490
|
JSON.stringify({ sub: profile.id, email: profile.email, full_name: profile.name, ...profile.metadata })
|
|
485
491
|
]);
|
|
486
492
|
const user = created.rows[0];
|
|
487
|
-
if (
|
|
493
|
+
if (user) {
|
|
494
|
+
userId = user.id;
|
|
495
|
+
createdUserId = user.id;
|
|
496
|
+
} else if (userEmail) {
|
|
497
|
+
const existingEmail = await query(`select id from auth.users where lower(email) = $1 for update`, [userEmail]);
|
|
498
|
+
userId = existingEmail.rows[0]?.id;
|
|
499
|
+
if (!userId)
|
|
500
|
+
throw new Error("OAuth email conflict has no winner");
|
|
501
|
+
await this.assertUserEligible(query, userId);
|
|
502
|
+
} else {
|
|
488
503
|
throw new Error("OAuth user creation returned no row");
|
|
489
|
-
|
|
504
|
+
}
|
|
490
505
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
506
|
+
const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
|
|
507
|
+
const linked = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
508
|
+
values ($1, $2, $3, $4::jsonb)
|
|
509
|
+
on conflict (provider, provider_id) do nothing
|
|
510
|
+
returning user_id`, [userId, provider, profile.id, identityData]);
|
|
511
|
+
if (linked.rows[0])
|
|
512
|
+
return linked.rows[0].user_id;
|
|
513
|
+
const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
|
|
514
|
+
const racedUserId = raced.rows[0]?.user_id;
|
|
515
|
+
if (!racedUserId)
|
|
516
|
+
throw new Error("OAuth identity conflict has no winner");
|
|
517
|
+
if (createdUserId && createdUserId !== racedUserId) {
|
|
518
|
+
await query(`delete from auth.users where id = $1`, [createdUserId]);
|
|
519
|
+
}
|
|
520
|
+
await this.assertUserEligible(query, racedUserId);
|
|
521
|
+
await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
|
|
522
|
+
where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
|
|
523
|
+
await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
|
|
524
|
+
return racedUserId;
|
|
496
525
|
}
|
|
497
526
|
async assertUserEligible(query, userId) {
|
|
498
527
|
const users = await query(`select deleted_at is not null as is_deleted,
|
|
@@ -1201,6 +1230,12 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
|
|
|
1201
1230
|
function authError(status, errorCode, msg) {
|
|
1202
1231
|
return json(status, { code: status, error_code: errorCode, msg });
|
|
1203
1232
|
}
|
|
1233
|
+
function normalizeEmailInput(value) {
|
|
1234
|
+
if (typeof value !== "string")
|
|
1235
|
+
return null;
|
|
1236
|
+
const email = value.trim().toLowerCase();
|
|
1237
|
+
return email || null;
|
|
1238
|
+
}
|
|
1204
1239
|
function randomOtp(length) {
|
|
1205
1240
|
const n = Math.max(6, Math.min(10, Math.floor(length)));
|
|
1206
1241
|
let code = "";
|
|
@@ -1391,8 +1426,10 @@ class AuthHandler {
|
|
|
1391
1426
|
} catch (e) {
|
|
1392
1427
|
if (e instanceof OAuthAccountIneligibleError)
|
|
1393
1428
|
return authError(400, e.code, e.message);
|
|
1394
|
-
|
|
1395
|
-
|
|
1429
|
+
try {
|
|
1430
|
+
this.config.log?.(`[auth] request_failed code=${databaseDiagnosticCode(e)}`);
|
|
1431
|
+
} catch {}
|
|
1432
|
+
return authError(500, "unexpected_failure", "Unable to complete authentication request");
|
|
1396
1433
|
}
|
|
1397
1434
|
}
|
|
1398
1435
|
async oauthCallbackUrl(req, url) {
|
|
@@ -1407,7 +1444,7 @@ class AuthHandler {
|
|
|
1407
1444
|
}
|
|
1408
1445
|
async signup(req) {
|
|
1409
1446
|
const body = await req.json().catch(() => ({}));
|
|
1410
|
-
if (!
|
|
1447
|
+
if (!("email" in body) && !("password" in body)) {
|
|
1411
1448
|
if (!this.settings.anonymousUsers) {
|
|
1412
1449
|
return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
|
|
1413
1450
|
}
|
|
@@ -1419,29 +1456,34 @@ class AuthHandler {
|
|
|
1419
1456
|
if (this.settings.disableSignup) {
|
|
1420
1457
|
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1421
1458
|
}
|
|
1422
|
-
|
|
1459
|
+
const email = normalizeEmailInput(body.email);
|
|
1460
|
+
if (!email || !body.password) {
|
|
1423
1461
|
return authError(400, "validation_failed", "Signup requires a valid email and password");
|
|
1424
1462
|
}
|
|
1425
1463
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
1426
1464
|
return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
1427
1465
|
}
|
|
1428
|
-
const email = body.email.toLowerCase().trim();
|
|
1429
|
-
const existing = await this.db.query(`select id from auth.users where email = $1`, [email]);
|
|
1430
|
-
if (existing.rows.length > 0) {
|
|
1431
|
-
return authError(422, "user_already_exists", "User already registered");
|
|
1432
|
-
}
|
|
1433
1466
|
const hashed = await hashPassword(body.password);
|
|
1434
1467
|
const autoconfirm = this.settings.autoconfirm;
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1468
|
+
const newUser = await this.db.transaction(async (query) => {
|
|
1469
|
+
const res = await query(`insert into auth.users
|
|
1470
|
+
(aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
|
|
1471
|
+
raw_app_meta_data, raw_user_meta_data)
|
|
1472
|
+
values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end,
|
|
1473
|
+
case when $4 then now() else null end,
|
|
1474
|
+
'{"provider":"email","providers":["email"]}', $3)
|
|
1475
|
+
on conflict (email) do nothing
|
|
1476
|
+
returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
|
|
1477
|
+
const user = res.rows[0];
|
|
1478
|
+
if (!user)
|
|
1479
|
+
return null;
|
|
1480
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1481
|
+
values ($1, 'email', $2, $3)
|
|
1482
|
+
on conflict (provider, provider_id) do nothing`, [user.id, user.id, JSON.stringify({ sub: user.id, email: user.email })]);
|
|
1483
|
+
return user;
|
|
1484
|
+
});
|
|
1485
|
+
if (!newUser)
|
|
1486
|
+
return authError(422, "user_already_exists", "User already registered");
|
|
1445
1487
|
await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
|
|
1446
1488
|
if (!autoconfirm) {
|
|
1447
1489
|
await this.issueToken(email, "otp", false, "confirm");
|
|
@@ -1455,7 +1497,7 @@ class AuthHandler {
|
|
|
1455
1497
|
if (grantType === "password") {
|
|
1456
1498
|
const email = (body.email ?? "").toLowerCase().trim();
|
|
1457
1499
|
const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1458
|
-
from auth.users where email = $1 and deleted_at is null`, [email]);
|
|
1500
|
+
from auth.users where lower(email) = $1 and deleted_at is null`, [email]);
|
|
1459
1501
|
const user = res.rows[0];
|
|
1460
1502
|
if (user?.encrypted_password && user.is_banned) {
|
|
1461
1503
|
return authError(400, "user_banned", "User is banned");
|
|
@@ -1468,7 +1510,7 @@ class AuthHandler {
|
|
|
1468
1510
|
const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1469
1511
|
from auth.users where id = $1 for update`, [user.id]);
|
|
1470
1512
|
const current = currentResult.rows[0];
|
|
1471
|
-
if (!current || current.deleted_at !== null || !current.encrypted_password || current.email !== email) {
|
|
1513
|
+
if (!current || current.deleted_at !== null || !current.encrypted_password || current.email?.toLowerCase().trim() !== email) {
|
|
1472
1514
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
1473
1515
|
}
|
|
1474
1516
|
if (current.is_banned) {
|
|
@@ -1523,18 +1565,12 @@ class AuthHandler {
|
|
|
1523
1565
|
const body = await req.json().catch(() => ({}));
|
|
1524
1566
|
const sets = [];
|
|
1525
1567
|
const params = [];
|
|
1526
|
-
|
|
1527
|
-
|
|
1568
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
1569
|
+
if (body.email !== undefined && !requestedEmail) {
|
|
1570
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
1528
1571
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
const email = body.email.toLowerCase().trim();
|
|
1532
|
-
const clash = await this.db.query(`select id from auth.users where email = $1 and id <> $2`, [email, user.id]);
|
|
1533
|
-
if (clash.rows.length > 0) {
|
|
1534
|
-
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1535
|
-
}
|
|
1536
|
-
params.push(email);
|
|
1537
|
-
sets.push(`email = $${params.length}, email_confirmed_at = now()`);
|
|
1572
|
+
if (requestedEmail && !this.settings.autoconfirm) {
|
|
1573
|
+
return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
|
|
1538
1574
|
}
|
|
1539
1575
|
if (body.password) {
|
|
1540
1576
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
@@ -1547,22 +1583,49 @@ class AuthHandler {
|
|
|
1547
1583
|
params.push(JSON.stringify(body.data));
|
|
1548
1584
|
sets.push(`raw_user_meta_data = coalesce(raw_user_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
|
|
1549
1585
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1586
|
+
let response;
|
|
1587
|
+
try {
|
|
1588
|
+
response = await this.db.transaction(async (query) => {
|
|
1589
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
1590
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
1591
|
+
const current = currentResult.rows[0];
|
|
1592
|
+
if (!current)
|
|
1593
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1594
|
+
const transactionSets = [...sets];
|
|
1595
|
+
const transactionParams = [...params];
|
|
1596
|
+
const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
|
|
1597
|
+
if (requestedEmail) {
|
|
1598
|
+
const email = requestedEmail;
|
|
1599
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [email, current.id]);
|
|
1600
|
+
if (clash.rows.length > 0) {
|
|
1601
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1602
|
+
}
|
|
1603
|
+
transactionParams.push(email);
|
|
1604
|
+
transactionSets.push(`email = $${transactionParams.length}, email_confirmed_at = now()`);
|
|
1605
|
+
}
|
|
1606
|
+
if (upgradingAnon) {
|
|
1607
|
+
transactionSets.push(`is_anonymous = false`);
|
|
1608
|
+
transactionSets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
|
|
1609
|
+
}
|
|
1610
|
+
if (transactionSets.length === 0) {
|
|
1611
|
+
return json(200, await this.userJsonWithRelations(current, query));
|
|
1612
|
+
}
|
|
1613
|
+
transactionParams.push(current.id);
|
|
1614
|
+
const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
|
|
1615
|
+
const updated = updatedResult.rows[0];
|
|
1616
|
+
if (!updated)
|
|
1617
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1618
|
+
if (requestedEmail)
|
|
1619
|
+
await this.ensureEmailIdentity(updated, query);
|
|
1620
|
+
return json(200, await this.userJsonWithRelations(updated, query));
|
|
1621
|
+
});
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
1624
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1625
|
+
}
|
|
1626
|
+
throw error;
|
|
1556
1627
|
}
|
|
1557
|
-
|
|
1558
|
-
const payload = await this.db.transaction(async (query) => {
|
|
1559
|
-
const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
|
|
1560
|
-
const updated = res.rows[0];
|
|
1561
|
-
if (body.email)
|
|
1562
|
-
await this.ensureEmailIdentity(updated, query);
|
|
1563
|
-
return this.userJsonWithRelations(updated, query);
|
|
1564
|
-
});
|
|
1565
|
-
return json(200, payload);
|
|
1628
|
+
return response;
|
|
1566
1629
|
}
|
|
1567
1630
|
async logout(req, url) {
|
|
1568
1631
|
const claims = await this.claimsFromBearer(req);
|
|
@@ -1588,24 +1651,39 @@ class AuthHandler {
|
|
|
1588
1651
|
}
|
|
1589
1652
|
async issueToken(email, tokenType, createUser, flavor = "login") {
|
|
1590
1653
|
const normalized = email.toLowerCase().trim();
|
|
1591
|
-
let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
|
|
1592
|
-
let user = res.rows[0];
|
|
1593
|
-
if (!user) {
|
|
1594
|
-
if (!createUser)
|
|
1595
|
-
return json(200, {});
|
|
1596
|
-
if (this.settings.disableSignup)
|
|
1597
|
-
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1598
|
-
res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1599
|
-
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1600
|
-
returning *`, [normalized]);
|
|
1601
|
-
user = res.rows[0];
|
|
1602
|
-
}
|
|
1603
|
-
const code = randomOtp(this.settings.otpLength);
|
|
1604
|
-
const linkToken = randomToken(24);
|
|
1605
1654
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1606
|
-
await this.db.
|
|
1607
|
-
|
|
1608
|
-
|
|
1655
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
1656
|
+
let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
1657
|
+
if (!user) {
|
|
1658
|
+
if (!createUser)
|
|
1659
|
+
return { kind: "unknown" };
|
|
1660
|
+
if (this.settings.disableSignup)
|
|
1661
|
+
return { kind: "signup_disabled" };
|
|
1662
|
+
const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1663
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1664
|
+
on conflict (email) do nothing
|
|
1665
|
+
returning *`, [normalized]);
|
|
1666
|
+
user = inserted.rows[0];
|
|
1667
|
+
if (!user) {
|
|
1668
|
+
user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
if (!user)
|
|
1672
|
+
return { kind: "unknown" };
|
|
1673
|
+
const code = randomOtp(this.settings.otpLength);
|
|
1674
|
+
const linkToken = randomToken(24);
|
|
1675
|
+
const tokenTypes = tokenType === "otp" ? ["otp", "magiclink"] : [tokenType];
|
|
1676
|
+
await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
|
|
1677
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
1678
|
+
values ($1, $2, $3, $4, now() + $7::interval), ($1, $2, $5, $6, now() + $7::interval)`, [user.id, normalized, tokenType, code, tokenType === "otp" ? "magiclink" : tokenType, linkToken, expiry]);
|
|
1679
|
+
return { kind: "issued", user, code, linkToken };
|
|
1680
|
+
});
|
|
1681
|
+
if (prepared.kind === "unknown")
|
|
1682
|
+
return json(200, {});
|
|
1683
|
+
if (prepared.kind === "signup_disabled") {
|
|
1684
|
+
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1685
|
+
}
|
|
1686
|
+
const { user, code, linkToken } = prepared;
|
|
1609
1687
|
const kind = tokenType === "otp" ? "magiclink" : tokenType;
|
|
1610
1688
|
const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
|
|
1611
1689
|
await this.config.mailer.send({
|
|
@@ -1634,9 +1712,10 @@ Or sign in with this link: ${link}`
|
|
|
1634
1712
|
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
1635
1713
|
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1636
1714
|
}
|
|
1637
|
-
|
|
1715
|
+
const email = normalizeEmailInput(body.email);
|
|
1716
|
+
if (!email)
|
|
1638
1717
|
return authError(400, "validation_failed", "email or phone is required");
|
|
1639
|
-
return this.limit("otp", req) ?? this.issueToken(
|
|
1718
|
+
return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
|
|
1640
1719
|
}
|
|
1641
1720
|
async issuePhoneToken(phone, createUser, metadata) {
|
|
1642
1721
|
const sender = this.config.smsSender;
|
|
@@ -1790,9 +1869,10 @@ Or sign in with this link: ${link}`
|
|
|
1790
1869
|
}
|
|
1791
1870
|
async sendRecovery(req) {
|
|
1792
1871
|
const body = await req.json().catch(() => ({}));
|
|
1793
|
-
|
|
1872
|
+
const email = normalizeEmailInput(body.email);
|
|
1873
|
+
if (!email)
|
|
1794
1874
|
return authError(400, "validation_failed", "email is required");
|
|
1795
|
-
return this.issueToken(
|
|
1875
|
+
return this.issueToken(email, "recovery", false);
|
|
1796
1876
|
}
|
|
1797
1877
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1798
1878
|
async redeem(token, types, email) {
|
|
@@ -1816,8 +1896,8 @@ Or sign in with this link: ${link}`
|
|
|
1816
1896
|
return claimedToken ? this.confirmEmailTokenUser(query, claimedToken) : null;
|
|
1817
1897
|
}
|
|
1818
1898
|
async lockAndDeleteEmailToken(query, candidate, redemption) {
|
|
1819
|
-
const eligible = await query(`select id from auth.users where id = $1 and
|
|
1820
|
-
and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
|
|
1899
|
+
const eligible = await query(`select id from auth.users where id = $1 and lower(email) = lower($2)
|
|
1900
|
+
and deleted_at is null and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id, candidate.email]);
|
|
1821
1901
|
if (eligible.rows.length === 0)
|
|
1822
1902
|
return null;
|
|
1823
1903
|
const claimedTokens = await query(`delete from auth.one_time_tokens
|
|
@@ -1827,15 +1907,19 @@ Or sign in with this link: ${link}`
|
|
|
1827
1907
|
return claimedTokens.rows[0] ?? null;
|
|
1828
1908
|
}
|
|
1829
1909
|
async confirmEmailTokenUser(query, claimedToken) {
|
|
1830
|
-
await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
|
|
1910
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1)`, [claimedToken.email]);
|
|
1831
1911
|
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1832
1912
|
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1833
|
-
|
|
1913
|
+
const user = users.rows[0];
|
|
1914
|
+
if (!user)
|
|
1915
|
+
return null;
|
|
1916
|
+
await this.ensureEmailIdentity(user, query);
|
|
1917
|
+
return user;
|
|
1834
1918
|
}
|
|
1835
1919
|
async findEmailTokenCandidate(query, redemption) {
|
|
1836
1920
|
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1837
1921
|
where token = $1 and token_type = any($2::text[])
|
|
1838
|
-
and ($3::text is null or email = $3) and expires_at > now()
|
|
1922
|
+
and ($3::text is null or lower(email) = lower($3)) and expires_at > now()
|
|
1839
1923
|
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1840
1924
|
return candidates.rows[0] ?? null;
|
|
1841
1925
|
}
|
|
@@ -1843,8 +1927,8 @@ Or sign in with this link: ${link}`
|
|
|
1843
1927
|
if (!redemption.email)
|
|
1844
1928
|
return;
|
|
1845
1929
|
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1846
|
-
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1847
|
-
await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1930
|
+
where lower(email) = lower($1) and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1931
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1) and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1848
1932
|
}
|
|
1849
1933
|
async verifyToken(req) {
|
|
1850
1934
|
const body = await req.json().catch(() => ({}));
|
|
@@ -1880,11 +1964,10 @@ Or sign in with this link: ${link}`
|
|
|
1880
1964
|
async redeemPhoneOtp(phone, code) {
|
|
1881
1965
|
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1882
1966
|
return this.db.transaction(async (query) => {
|
|
1883
|
-
const
|
|
1967
|
+
const candidate = await query(`select user_id from auth.one_time_tokens
|
|
1884
1968
|
where phone = $1 and token_type = 'sms' and token = $2
|
|
1885
|
-
and expires_at > now() and attempts < $3
|
|
1886
|
-
|
|
1887
|
-
const userId = claimed.rows[0]?.user_id;
|
|
1969
|
+
and expires_at > now() and attempts < $3`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1970
|
+
const userId = candidate.rows[0]?.user_id;
|
|
1888
1971
|
if (!userId) {
|
|
1889
1972
|
const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1890
1973
|
where phone = $1 and token_type = 'sms' and expires_at > now()
|
|
@@ -1896,6 +1979,22 @@ Or sign in with this link: ${link}`
|
|
|
1896
1979
|
await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
|
|
1897
1980
|
return null;
|
|
1898
1981
|
}
|
|
1982
|
+
const currentUser = await query(`select phone,
|
|
1983
|
+
deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
|
|
1984
|
+
from auth.users where id = $1 for update`, [userId]);
|
|
1985
|
+
const current = currentUser.rows[0];
|
|
1986
|
+
if (!current || current.phone !== phone)
|
|
1987
|
+
return null;
|
|
1988
|
+
if (!current.auth_eligible) {
|
|
1989
|
+
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1990
|
+
return null;
|
|
1991
|
+
}
|
|
1992
|
+
const claimed = await query(`delete from auth.one_time_tokens
|
|
1993
|
+
where phone = $1 and token_type = 'sms' and token = $2
|
|
1994
|
+
and expires_at > now() and attempts < $3 and user_id = $4
|
|
1995
|
+
returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS, userId]);
|
|
1996
|
+
if (claimed.rows.length === 0)
|
|
1997
|
+
return null;
|
|
1899
1998
|
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1900
1999
|
const users = await query(`update auth.users
|
|
1901
2000
|
set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
|
|
@@ -1960,14 +2059,18 @@ Or sign in with this link: ${link}`
|
|
|
1960
2059
|
}
|
|
1961
2060
|
if (path === "admin/users" && method === "POST") {
|
|
1962
2061
|
const body = await req.json().catch(() => ({}));
|
|
1963
|
-
|
|
2062
|
+
const email = normalizeEmailInput(body.email);
|
|
2063
|
+
if (!email)
|
|
1964
2064
|
return authError(400, "validation_failed", "email is required");
|
|
1965
|
-
const email = body.email;
|
|
1966
2065
|
const hashed = body.password ? await hashPassword(body.password) : null;
|
|
1967
2066
|
const created = await this.db.transaction(async (query) => {
|
|
2067
|
+
const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email.toLowerCase().trim()]);
|
|
2068
|
+
if (existing.rows.length > 0)
|
|
2069
|
+
return null;
|
|
1968
2070
|
const res = await query(`insert into auth.users
|
|
1969
2071
|
(aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
|
|
1970
2072
|
values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
|
|
2073
|
+
on conflict (email) do nothing
|
|
1971
2074
|
returning *`, [
|
|
1972
2075
|
email.toLowerCase().trim(),
|
|
1973
2076
|
hashed,
|
|
@@ -1976,9 +2079,13 @@ Or sign in with this link: ${link}`
|
|
|
1976
2079
|
JSON.stringify(body.user_metadata ?? {})
|
|
1977
2080
|
]);
|
|
1978
2081
|
const user = res.rows[0];
|
|
2082
|
+
if (!user)
|
|
2083
|
+
return null;
|
|
1979
2084
|
await this.ensureEmailIdentity(user, query);
|
|
1980
2085
|
return this.userJsonWithRelations(user, query);
|
|
1981
2086
|
});
|
|
2087
|
+
if (!created)
|
|
2088
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1982
2089
|
return json(200, created);
|
|
1983
2090
|
}
|
|
1984
2091
|
if (idMatch && method === "GET") {
|
|
@@ -1991,8 +2098,11 @@ Or sign in with this link: ${link}`
|
|
|
1991
2098
|
const body = await req.json().catch(() => ({}));
|
|
1992
2099
|
const sets = [];
|
|
1993
2100
|
const params = [];
|
|
1994
|
-
|
|
1995
|
-
|
|
2101
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
2102
|
+
if (body.email !== undefined && !requestedEmail)
|
|
2103
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
2104
|
+
if (requestedEmail) {
|
|
2105
|
+
params.push(requestedEmail);
|
|
1996
2106
|
sets.push(`email = $${params.length}`);
|
|
1997
2107
|
}
|
|
1998
2108
|
if (typeof body.password === "string") {
|
|
@@ -2012,15 +2122,33 @@ Or sign in with this link: ${link}`
|
|
|
2012
2122
|
if (sets.length === 0)
|
|
2013
2123
|
return authError(400, "validation_failed", "nothing to update");
|
|
2014
2124
|
params.push(idMatch[1]);
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2125
|
+
let updated;
|
|
2126
|
+
let emailConflict = false;
|
|
2127
|
+
try {
|
|
2128
|
+
updated = await this.db.transaction(async (query) => {
|
|
2129
|
+
if (requestedEmail) {
|
|
2130
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [requestedEmail, idMatch[1]]);
|
|
2131
|
+
if (clash.rows.length > 0) {
|
|
2132
|
+
emailConflict = true;
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
|
|
2137
|
+
const user = res.rows[0];
|
|
2138
|
+
if (!user)
|
|
2139
|
+
return null;
|
|
2140
|
+
if (requestedEmail)
|
|
2141
|
+
await this.ensureEmailIdentity(user, query);
|
|
2142
|
+
return this.userJsonWithRelations(user, query);
|
|
2143
|
+
});
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
2146
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2147
|
+
}
|
|
2148
|
+
throw error;
|
|
2149
|
+
}
|
|
2150
|
+
if (emailConflict)
|
|
2151
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2024
2152
|
if (!updated)
|
|
2025
2153
|
return authError(404, "user_not_found", "User not found");
|
|
2026
2154
|
return json(200, updated);
|
|
@@ -2035,26 +2163,30 @@ Or sign in with this link: ${link}`
|
|
|
2035
2163
|
if (body.type !== "magiclink") {
|
|
2036
2164
|
return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
|
|
2037
2165
|
}
|
|
2038
|
-
|
|
2166
|
+
const email = normalizeEmailInput(body.email);
|
|
2167
|
+
if (!email)
|
|
2039
2168
|
return authError(400, "validation_failed", "email is required");
|
|
2040
|
-
const email = body.email.toLowerCase().trim();
|
|
2041
2169
|
const emailOtp = randomOtp(this.settings.otpLength);
|
|
2042
2170
|
const hashedToken = await sha256Hex(randomToken(32));
|
|
2043
2171
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
2044
2172
|
const requestUrl = new URL(req.url);
|
|
2045
2173
|
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
2046
2174
|
const user = await this.db.transaction(async (query) => {
|
|
2047
|
-
const
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2175
|
+
const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
|
|
2176
|
+
let linkedUser = existing.rows[0];
|
|
2177
|
+
if (!linkedUser) {
|
|
2178
|
+
const result = await query(`insert into auth.users
|
|
2179
|
+
(aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
2180
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
|
|
2181
|
+
on conflict (email) do update set email = excluded.email
|
|
2182
|
+
returning *`, [email, JSON.stringify(body.data ?? {})]);
|
|
2183
|
+
linkedUser = result.rows[0];
|
|
2184
|
+
}
|
|
2053
2185
|
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
2054
2186
|
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
2055
2187
|
on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
|
|
2056
2188
|
await query(`delete from auth.one_time_tokens
|
|
2057
|
-
where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
2189
|
+
where lower(email) = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
2058
2190
|
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
2059
2191
|
values ($1, $2, 'otp', $3, now() + $5::interval),
|
|
2060
2192
|
($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
|
|
@@ -2146,17 +2278,7 @@ Or sign in with this link: ${link}`
|
|
|
2146
2278
|
if (!this.settings.totpEnrollEnabled) {
|
|
2147
2279
|
return authError(422, "mfa_totp_enroll_disabled", "TOTP enrollment is disabled");
|
|
2148
2280
|
}
|
|
2149
|
-
const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
|
|
2150
|
-
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2151
|
-
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2152
|
-
}
|
|
2153
2281
|
const friendlyName = body.friendly_name ?? null;
|
|
2154
|
-
if (friendlyName) {
|
|
2155
|
-
const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
|
|
2156
|
-
if (dup.rows.length > 0) {
|
|
2157
|
-
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2158
|
-
}
|
|
2159
|
-
}
|
|
2160
2282
|
const secret = generateTotpSecret();
|
|
2161
2283
|
let issuer = body.issuer;
|
|
2162
2284
|
if (!issuer) {
|
|
@@ -2166,16 +2288,33 @@ Or sign in with this link: ${link}`
|
|
|
2166
2288
|
issuer = "supacloud-lite";
|
|
2167
2289
|
}
|
|
2168
2290
|
}
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2291
|
+
return this.db.transaction(async (query) => {
|
|
2292
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2293
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2294
|
+
const current = currentResult.rows[0];
|
|
2295
|
+
if (!current)
|
|
2296
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2297
|
+
const uri = otpauthUri({ secret, account: current.email || current.id, issuer });
|
|
2298
|
+
const enrolled = await query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [current.id]);
|
|
2299
|
+
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2300
|
+
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2301
|
+
}
|
|
2302
|
+
if (friendlyName) {
|
|
2303
|
+
const dup = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [current.id, friendlyName]);
|
|
2304
|
+
if (dup.rows.length > 0) {
|
|
2305
|
+
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
const ins = await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status, secret)
|
|
2309
|
+
values ($1, $2, 'totp', 'unverified', $3) returning id`, [current.id, friendlyName, secret]);
|
|
2310
|
+
const id = ins.rows[0].id;
|
|
2311
|
+
return json(200, {
|
|
2312
|
+
id,
|
|
2313
|
+
type: "totp",
|
|
2314
|
+
friendly_name: friendlyName,
|
|
2315
|
+
status: "unverified",
|
|
2316
|
+
totp: { qr_code: qrSvgDataUri(uri), secret, uri }
|
|
2317
|
+
});
|
|
2179
2318
|
});
|
|
2180
2319
|
}
|
|
2181
2320
|
async challengeFactor(req, factorId) {
|
|
@@ -2185,22 +2324,31 @@ Or sign in with this link: ${link}`
|
|
|
2185
2324
|
if (!this.settings.totpVerifyEnabled) {
|
|
2186
2325
|
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2187
2326
|
}
|
|
2188
|
-
const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2189
|
-
if (fr.rows.length === 0)
|
|
2190
|
-
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2191
2327
|
const expiresAt = new Date(Date.now() + 300000).toISOString();
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2328
|
+
return this.db.transaction(async (query) => {
|
|
2329
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2330
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2331
|
+
if (!currentResult.rows[0])
|
|
2332
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2333
|
+
const fr = await query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2334
|
+
if (fr.rows.length === 0)
|
|
2335
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2336
|
+
const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
|
|
2337
|
+
const c = ins.rows[0];
|
|
2338
|
+
return json(200, {
|
|
2339
|
+
id: c.id,
|
|
2340
|
+
type: "totp",
|
|
2341
|
+
expires_at: Math.floor(new Date(c.expires_at).getTime() / 1000)
|
|
2342
|
+
});
|
|
2198
2343
|
});
|
|
2199
2344
|
}
|
|
2200
2345
|
async verifyFactor(req, factorId) {
|
|
2201
2346
|
const user = await this.userFromBearer(req);
|
|
2202
2347
|
if (!user)
|
|
2203
2348
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2349
|
+
if (!this.settings.totpVerifyEnabled) {
|
|
2350
|
+
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2351
|
+
}
|
|
2204
2352
|
const body = await req.json().catch(() => ({}));
|
|
2205
2353
|
const fr = await this.db.query(`select id, secret, status from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2206
2354
|
const factor = fr.rows[0];
|
|
@@ -2225,12 +2373,17 @@ Or sign in with this link: ${link}`
|
|
|
2225
2373
|
const currentUser = currentUsers.rows[0];
|
|
2226
2374
|
if (!currentUser)
|
|
2227
2375
|
return null;
|
|
2376
|
+
const currentFactors = await query(`select id, secret, status from auth.mfa_factors
|
|
2377
|
+
where id = $1 and user_id = $2 for update`, [factorId, user.id]);
|
|
2378
|
+
const currentFactor = currentFactors.rows[0];
|
|
2379
|
+
if (!currentFactor || !await verifyTotp(currentFactor.secret, body.code ?? ""))
|
|
2380
|
+
return null;
|
|
2228
2381
|
const claimed = await query(`update auth.mfa_challenges set verified_at = now()
|
|
2229
2382
|
where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
|
|
2230
2383
|
returning id`, [challenge.id, factorId]);
|
|
2231
2384
|
if (claimed.rows.length === 0)
|
|
2232
2385
|
return null;
|
|
2233
|
-
if (
|
|
2386
|
+
if (currentFactor.status !== "verified") {
|
|
2234
2387
|
await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
|
|
2235
2388
|
}
|
|
2236
2389
|
return this.sessionFor(currentUser, undefined, {
|
|
@@ -2250,10 +2403,16 @@ Or sign in with this link: ${link}`
|
|
|
2250
2403
|
const user = await this.userFromBearer(req);
|
|
2251
2404
|
if (!user)
|
|
2252
2405
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2406
|
+
return this.db.transaction(async (query) => {
|
|
2407
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2408
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2409
|
+
if (!currentResult.rows[0])
|
|
2410
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2411
|
+
const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
|
|
2412
|
+
if (del.rows.length === 0)
|
|
2413
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2414
|
+
return json(200, { id: factorId });
|
|
2415
|
+
});
|
|
2257
2416
|
}
|
|
2258
2417
|
async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
2259
2418
|
const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
|
|
@@ -9238,7 +9397,8 @@ function applyResize(image, metadata, options) {
|
|
|
9238
9397
|
async function transformImage(source, options, knownSourceSize, configuredLimits = {}) {
|
|
9239
9398
|
const limits = resolveImageTransformLimits(configuredLimits);
|
|
9240
9399
|
const actualSourceSize = source instanceof Uint8Array ? source.byteLength : await Promise.resolve(source.size);
|
|
9241
|
-
const
|
|
9400
|
+
const declaredSourceSize = typeof knownSourceSize === "number" && Number.isFinite(knownSourceSize) && knownSourceSize >= 0 ? knownSourceSize : 0;
|
|
9401
|
+
const sourceSize = Math.max(declaredSourceSize, actualSourceSize);
|
|
9242
9402
|
if (sourceSize > limits.maxInputBytes) {
|
|
9243
9403
|
return imageTransformInputTooLarge(limits.maxInputBytes);
|
|
9244
9404
|
}
|
|
@@ -9464,7 +9624,7 @@ function isRenderableActiveType(contentType) {
|
|
|
9464
9624
|
return t === "text/html" || t === "application/xhtml+xml" || t === "image/svg+xml" || t === "application/xml" || t === "text/xml";
|
|
9465
9625
|
}
|
|
9466
9626
|
var TUS_VERSION = "1.0.0";
|
|
9467
|
-
var DEFAULT_FILE_SIZE_LIMIT =
|
|
9627
|
+
var DEFAULT_FILE_SIZE_LIMIT = 500 * 1024 * 1024;
|
|
9468
9628
|
var MULTIPART_OVERHEAD_LIMIT = 1024 * 1024;
|
|
9469
9629
|
var MAX_CONCURRENT_TUS_UPLOADS = 4;
|
|
9470
9630
|
var MAX_COMPLETED_TUS_UPLOADS = 64;
|