@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/index.js
CHANGED
|
@@ -88,7 +88,7 @@ function randomToken(bytes = 32) {
|
|
|
88
88
|
// package.json
|
|
89
89
|
var package_default = {
|
|
90
90
|
name: "@supacloud/lite",
|
|
91
|
-
version: "0.
|
|
91
|
+
version: "0.32.0",
|
|
92
92
|
description: "Bun-native, single-project Supabase-compatible backend powered by PGlite or native PostgreSQL",
|
|
93
93
|
type: "module",
|
|
94
94
|
license: "Apache-2.0",
|
|
@@ -147,16 +147,16 @@ var package_default = {
|
|
|
147
147
|
"@electric-sql/pglite-pgmq": "0.0.8",
|
|
148
148
|
"@electric-sql/pglite-pgvector": "0.0.11",
|
|
149
149
|
"@electric-sql/pglite-postgis": "0.2.10",
|
|
150
|
-
"@supacloud/db": "0.
|
|
150
|
+
"@supacloud/db": "0.19.0",
|
|
151
151
|
"exact-mirror": "1.2.6",
|
|
152
152
|
tar: "^7.5.22",
|
|
153
153
|
typebox: "1.3.37"
|
|
154
154
|
},
|
|
155
155
|
devDependencies: {
|
|
156
|
-
"@supacloud/app": "0.
|
|
157
|
-
"@supacloud/contracts": "0.
|
|
158
|
-
"@supacloud/compiler": "0.
|
|
159
|
-
"@supacloud/elysia": "0.
|
|
156
|
+
"@supacloud/app": "0.29.0",
|
|
157
|
+
"@supacloud/contracts": "0.12.0",
|
|
158
|
+
"@supacloud/compiler": "0.48.0",
|
|
159
|
+
"@supacloud/elysia": "0.34.0",
|
|
160
160
|
"@supabase/supabase-js": "^2.117.3",
|
|
161
161
|
"@types/bun": "^1.4.3",
|
|
162
162
|
effect: "4.0.5",
|
|
@@ -165,13 +165,13 @@ var package_default = {
|
|
|
165
165
|
typescript: "^7.0.2"
|
|
166
166
|
},
|
|
167
167
|
overrides: {
|
|
168
|
-
"@supacloud/db": "0.
|
|
169
|
-
"@supacloud/app": "0.
|
|
170
|
-
"@supacloud/compiler": "0.
|
|
171
|
-
"@supacloud/elysia": "0.
|
|
172
|
-
"@supacloud/contracts": "0.
|
|
173
|
-
"@supacloud/commands": "0.
|
|
174
|
-
"@supacloud/delivery": "0.
|
|
168
|
+
"@supacloud/db": "0.19.0",
|
|
169
|
+
"@supacloud/app": "0.29.0",
|
|
170
|
+
"@supacloud/compiler": "0.48.0",
|
|
171
|
+
"@supacloud/elysia": "0.34.0",
|
|
172
|
+
"@supacloud/contracts": "0.12.0",
|
|
173
|
+
"@supacloud/commands": "0.10.0",
|
|
174
|
+
"@supacloud/delivery": "0.20.0",
|
|
175
175
|
"@graphql-tools/utils": "^12.0.4"
|
|
176
176
|
},
|
|
177
177
|
engines: {
|
|
@@ -373,9 +373,8 @@ class OAuthService {
|
|
|
373
373
|
try {
|
|
374
374
|
const token = await this.exchangeCode(provider, code);
|
|
375
375
|
profile = await this.fetchProfile(provider, token);
|
|
376
|
-
} catch
|
|
377
|
-
|
|
378
|
-
return redirect(`${flow.redirect_to}#error=server_error&error_description=${msg}`);
|
|
376
|
+
} catch {
|
|
377
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth provider request failed")}`);
|
|
379
378
|
}
|
|
380
379
|
let userId;
|
|
381
380
|
try {
|
|
@@ -384,7 +383,7 @@ class OAuthService {
|
|
|
384
383
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
385
384
|
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
386
385
|
}
|
|
387
|
-
|
|
386
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
|
|
388
387
|
}
|
|
389
388
|
if (flow.code_challenge) {
|
|
390
389
|
const authCode = randomToken(24);
|
|
@@ -399,13 +398,13 @@ class OAuthService {
|
|
|
399
398
|
if (e instanceof OAuthAccountIneligibleError) {
|
|
400
399
|
return redirect(`${flow.redirect_to}#error=access_denied&error_code=${e.code}`);
|
|
401
400
|
}
|
|
402
|
-
|
|
401
|
+
return redirect(`${flow.redirect_to}#error=server_error&error_description=${encodeURIComponent("OAuth callback failed")}`);
|
|
403
402
|
}
|
|
404
403
|
const hash = `#access_token=${session.access_token}&refresh_token=${session.refresh_token}&expires_in=${session.expires_in}&token_type=bearer&provider_token=`;
|
|
405
404
|
return redirect(`${flow.redirect_to}${hash}`);
|
|
406
405
|
}
|
|
407
406
|
async exchangePkce(authCode, verifier) {
|
|
408
|
-
const res = await this.db.query(`
|
|
407
|
+
const res = await this.db.query(`select * from auth.flow_state where auth_code = $1 and expires_at > now()`, [authCode]);
|
|
409
408
|
const flow = res.rows[0];
|
|
410
409
|
if (!flow || !flow.user_id)
|
|
411
410
|
return null;
|
|
@@ -417,7 +416,11 @@ class OAuthService {
|
|
|
417
416
|
} else if (flow.code_challenge && flow.code_challenge !== verifier) {
|
|
418
417
|
return null;
|
|
419
418
|
}
|
|
420
|
-
|
|
419
|
+
const claimed = await this.db.transaction(async (query) => {
|
|
420
|
+
const consumed = await query(`delete from auth.flow_state where auth_code = $1 and expires_at > now() returning *`, [authCode]);
|
|
421
|
+
return consumed.rows[0] ?? null;
|
|
422
|
+
});
|
|
423
|
+
return claimed?.user_id ?? null;
|
|
421
424
|
}
|
|
422
425
|
async exchangeCode(provider, code) {
|
|
423
426
|
const body = new URLSearchParams({
|
|
@@ -466,39 +469,65 @@ class OAuthService {
|
|
|
466
469
|
return existing.rows[0].user_id;
|
|
467
470
|
}
|
|
468
471
|
let userId;
|
|
469
|
-
const email = profile.email?.toLowerCase() ?? null;
|
|
472
|
+
const email = profile.email?.toLowerCase().trim() ?? null;
|
|
470
473
|
const verified = !!profile.email && profile.emailVerified === true;
|
|
471
474
|
if (email && verified) {
|
|
472
|
-
const byEmail = await query(`select id from auth.users where email = $1`, [email]);
|
|
475
|
+
const byEmail = await query(`select id from auth.users where lower(email) = $1`, [email]);
|
|
473
476
|
userId = byEmail.rows[0]?.id;
|
|
474
477
|
if (userId)
|
|
475
478
|
await this.assertUserEligible(query, userId);
|
|
476
479
|
}
|
|
480
|
+
let createdUserId;
|
|
477
481
|
if (!userId) {
|
|
478
482
|
let userEmail = verified ? email : null;
|
|
479
483
|
if (userEmail) {
|
|
480
|
-
const taken = await query(`select 1 from auth.users where email = $1`, [userEmail]);
|
|
484
|
+
const taken = await query(`select 1 from auth.users where lower(email) = $1`, [userEmail]);
|
|
481
485
|
if (taken.rows.length > 0)
|
|
482
486
|
userEmail = null;
|
|
483
487
|
}
|
|
484
488
|
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)
|
|
485
489
|
values ('authenticated','authenticated',$1, $2, now(),
|
|
486
|
-
$3::jsonb, $4::jsonb)
|
|
490
|
+
$3::jsonb, $4::jsonb)
|
|
491
|
+
on conflict (email) do nothing
|
|
492
|
+
returning id`, [
|
|
487
493
|
userEmail,
|
|
488
494
|
userEmail ? new Date().toISOString() : null,
|
|
489
495
|
JSON.stringify({ provider, providers: [provider] }),
|
|
490
496
|
JSON.stringify({ sub: profile.id, email: profile.email, full_name: profile.name, ...profile.metadata })
|
|
491
497
|
]);
|
|
492
498
|
const user = created.rows[0];
|
|
493
|
-
if (
|
|
499
|
+
if (user) {
|
|
500
|
+
userId = user.id;
|
|
501
|
+
createdUserId = user.id;
|
|
502
|
+
} else if (userEmail) {
|
|
503
|
+
const existingEmail = await query(`select id from auth.users where lower(email) = $1 for update`, [userEmail]);
|
|
504
|
+
userId = existingEmail.rows[0]?.id;
|
|
505
|
+
if (!userId)
|
|
506
|
+
throw new Error("OAuth email conflict has no winner");
|
|
507
|
+
await this.assertUserEligible(query, userId);
|
|
508
|
+
} else {
|
|
494
509
|
throw new Error("OAuth user creation returned no row");
|
|
495
|
-
|
|
510
|
+
}
|
|
496
511
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
512
|
+
const identityData = JSON.stringify({ sub: profile.id, email: profile.email, ...profile.metadata });
|
|
513
|
+
const linked = await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
514
|
+
values ($1, $2, $3, $4::jsonb)
|
|
515
|
+
on conflict (provider, provider_id) do nothing
|
|
516
|
+
returning user_id`, [userId, provider, profile.id, identityData]);
|
|
517
|
+
if (linked.rows[0])
|
|
518
|
+
return linked.rows[0].user_id;
|
|
519
|
+
const raced = await query(`select user_id from auth.identities where provider = $1 and provider_id = $2`, [provider, profile.id]);
|
|
520
|
+
const racedUserId = raced.rows[0]?.user_id;
|
|
521
|
+
if (!racedUserId)
|
|
522
|
+
throw new Error("OAuth identity conflict has no winner");
|
|
523
|
+
if (createdUserId && createdUserId !== racedUserId) {
|
|
524
|
+
await query(`delete from auth.users where id = $1`, [createdUserId]);
|
|
525
|
+
}
|
|
526
|
+
await this.assertUserEligible(query, racedUserId);
|
|
527
|
+
await query(`update auth.identities set last_sign_in_at = now(), identity_data = $3
|
|
528
|
+
where provider = $1 and provider_id = $2`, [provider, profile.id, identityData]);
|
|
529
|
+
await query(`update auth.users set last_sign_in_at = now() where id = $1`, [racedUserId]);
|
|
530
|
+
return racedUserId;
|
|
502
531
|
}
|
|
503
532
|
async assertUserEligible(query, userId) {
|
|
504
533
|
const users = await query(`select deleted_at is not null as is_deleted,
|
|
@@ -1207,6 +1236,12 @@ async function verifyTotp(secretBase32, code, window = 1, atMs = Date.now()) {
|
|
|
1207
1236
|
function authError(status, errorCode, msg) {
|
|
1208
1237
|
return json(status, { code: status, error_code: errorCode, msg });
|
|
1209
1238
|
}
|
|
1239
|
+
function normalizeEmailInput(value) {
|
|
1240
|
+
if (typeof value !== "string")
|
|
1241
|
+
return null;
|
|
1242
|
+
const email = value.trim().toLowerCase();
|
|
1243
|
+
return email || null;
|
|
1244
|
+
}
|
|
1210
1245
|
function randomOtp(length) {
|
|
1211
1246
|
const n = Math.max(6, Math.min(10, Math.floor(length)));
|
|
1212
1247
|
let code = "";
|
|
@@ -1397,8 +1432,10 @@ class AuthHandler {
|
|
|
1397
1432
|
} catch (e) {
|
|
1398
1433
|
if (e instanceof OAuthAccountIneligibleError)
|
|
1399
1434
|
return authError(400, e.code, e.message);
|
|
1400
|
-
|
|
1401
|
-
|
|
1435
|
+
try {
|
|
1436
|
+
this.config.log?.(`[auth] request_failed code=${databaseDiagnosticCode(e)}`);
|
|
1437
|
+
} catch {}
|
|
1438
|
+
return authError(500, "unexpected_failure", "Unable to complete authentication request");
|
|
1402
1439
|
}
|
|
1403
1440
|
}
|
|
1404
1441
|
async oauthCallbackUrl(req, url) {
|
|
@@ -1413,7 +1450,7 @@ class AuthHandler {
|
|
|
1413
1450
|
}
|
|
1414
1451
|
async signup(req) {
|
|
1415
1452
|
const body = await req.json().catch(() => ({}));
|
|
1416
|
-
if (!
|
|
1453
|
+
if (!("email" in body) && !("password" in body)) {
|
|
1417
1454
|
if (!this.settings.anonymousUsers) {
|
|
1418
1455
|
return authError(422, "anonymous_provider_disabled", "Anonymous sign-ins are disabled");
|
|
1419
1456
|
}
|
|
@@ -1425,29 +1462,34 @@ class AuthHandler {
|
|
|
1425
1462
|
if (this.settings.disableSignup) {
|
|
1426
1463
|
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1427
1464
|
}
|
|
1428
|
-
|
|
1465
|
+
const email = normalizeEmailInput(body.email);
|
|
1466
|
+
if (!email || !body.password) {
|
|
1429
1467
|
return authError(400, "validation_failed", "Signup requires a valid email and password");
|
|
1430
1468
|
}
|
|
1431
1469
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
1432
1470
|
return authError(422, "weak_password", `Password should be at least ${this.settings.minPasswordLength} characters.`);
|
|
1433
1471
|
}
|
|
1434
|
-
const email = body.email.toLowerCase().trim();
|
|
1435
|
-
const existing = await this.db.query(`select id from auth.users where email = $1`, [email]);
|
|
1436
|
-
if (existing.rows.length > 0) {
|
|
1437
|
-
return authError(422, "user_already_exists", "User already registered");
|
|
1438
|
-
}
|
|
1439
1472
|
const hashed = await hashPassword(body.password);
|
|
1440
1473
|
const autoconfirm = this.settings.autoconfirm;
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1474
|
+
const newUser = await this.db.transaction(async (query) => {
|
|
1475
|
+
const res = await query(`insert into auth.users
|
|
1476
|
+
(aud, role, email, encrypted_password, email_confirmed_at, last_sign_in_at,
|
|
1477
|
+
raw_app_meta_data, raw_user_meta_data)
|
|
1478
|
+
values ('authenticated', 'authenticated', $1, $2, case when $4 then now() else null end,
|
|
1479
|
+
case when $4 then now() else null end,
|
|
1480
|
+
'{"provider":"email","providers":["email"]}', $3)
|
|
1481
|
+
on conflict (email) do nothing
|
|
1482
|
+
returning *`, [email, hashed, JSON.stringify(body.data ?? {}), autoconfirm]);
|
|
1483
|
+
const user = res.rows[0];
|
|
1484
|
+
if (!user)
|
|
1485
|
+
return null;
|
|
1486
|
+
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
1487
|
+
values ($1, 'email', $2, $3)
|
|
1488
|
+
on conflict (provider, provider_id) do nothing`, [user.id, user.id, JSON.stringify({ sub: user.id, email: user.email })]);
|
|
1489
|
+
return user;
|
|
1490
|
+
});
|
|
1491
|
+
if (!newUser)
|
|
1492
|
+
return authError(422, "user_already_exists", "User already registered");
|
|
1451
1493
|
await this.audit("user_signedup", { actorId: newUser.id, actorEmail: email });
|
|
1452
1494
|
if (!autoconfirm) {
|
|
1453
1495
|
await this.issueToken(email, "otp", false, "confirm");
|
|
@@ -1461,7 +1503,7 @@ class AuthHandler {
|
|
|
1461
1503
|
if (grantType === "password") {
|
|
1462
1504
|
const email = (body.email ?? "").toLowerCase().trim();
|
|
1463
1505
|
const res = await this.db.query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1464
|
-
from auth.users where email = $1 and deleted_at is null`, [email]);
|
|
1506
|
+
from auth.users where lower(email) = $1 and deleted_at is null`, [email]);
|
|
1465
1507
|
const user = res.rows[0];
|
|
1466
1508
|
if (user?.encrypted_password && user.is_banned) {
|
|
1467
1509
|
return authError(400, "user_banned", "User is banned");
|
|
@@ -1474,7 +1516,7 @@ class AuthHandler {
|
|
|
1474
1516
|
const currentResult = await query(`select *, coalesce(banned_until > now(), false) as is_banned
|
|
1475
1517
|
from auth.users where id = $1 for update`, [user.id]);
|
|
1476
1518
|
const current = currentResult.rows[0];
|
|
1477
|
-
if (!current || current.deleted_at !== null || !current.encrypted_password || current.email !== email) {
|
|
1519
|
+
if (!current || current.deleted_at !== null || !current.encrypted_password || current.email?.toLowerCase().trim() !== email) {
|
|
1478
1520
|
return authError(400, "invalid_credentials", "Invalid login credentials");
|
|
1479
1521
|
}
|
|
1480
1522
|
if (current.is_banned) {
|
|
@@ -1529,18 +1571,12 @@ class AuthHandler {
|
|
|
1529
1571
|
const body = await req.json().catch(() => ({}));
|
|
1530
1572
|
const sets = [];
|
|
1531
1573
|
const params = [];
|
|
1532
|
-
|
|
1533
|
-
|
|
1574
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
1575
|
+
if (body.email !== undefined && !requestedEmail) {
|
|
1576
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
1534
1577
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
const email = body.email.toLowerCase().trim();
|
|
1538
|
-
const clash = await this.db.query(`select id from auth.users where email = $1 and id <> $2`, [email, user.id]);
|
|
1539
|
-
if (clash.rows.length > 0) {
|
|
1540
|
-
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1541
|
-
}
|
|
1542
|
-
params.push(email);
|
|
1543
|
-
sets.push(`email = $${params.length}, email_confirmed_at = now()`);
|
|
1578
|
+
if (requestedEmail && !this.settings.autoconfirm) {
|
|
1579
|
+
return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
|
|
1544
1580
|
}
|
|
1545
1581
|
if (body.password) {
|
|
1546
1582
|
if (body.password.length < this.settings.minPasswordLength) {
|
|
@@ -1553,22 +1589,49 @@ class AuthHandler {
|
|
|
1553
1589
|
params.push(JSON.stringify(body.data));
|
|
1554
1590
|
sets.push(`raw_user_meta_data = coalesce(raw_user_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
|
|
1555
1591
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1592
|
+
let response;
|
|
1593
|
+
try {
|
|
1594
|
+
response = await this.db.transaction(async (query) => {
|
|
1595
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
1596
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
1597
|
+
const current = currentResult.rows[0];
|
|
1598
|
+
if (!current)
|
|
1599
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1600
|
+
const transactionSets = [...sets];
|
|
1601
|
+
const transactionParams = [...params];
|
|
1602
|
+
const upgradingAnon = (current.is_anonymous ?? false) && requestedEmail !== undefined;
|
|
1603
|
+
if (requestedEmail) {
|
|
1604
|
+
const email = requestedEmail;
|
|
1605
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [email, current.id]);
|
|
1606
|
+
if (clash.rows.length > 0) {
|
|
1607
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1608
|
+
}
|
|
1609
|
+
transactionParams.push(email);
|
|
1610
|
+
transactionSets.push(`email = $${transactionParams.length}, email_confirmed_at = now()`);
|
|
1611
|
+
}
|
|
1612
|
+
if (upgradingAnon) {
|
|
1613
|
+
transactionSets.push(`is_anonymous = false`);
|
|
1614
|
+
transactionSets.push(`raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"provider":"email","providers":["email"]}'::jsonb`);
|
|
1615
|
+
}
|
|
1616
|
+
if (transactionSets.length === 0) {
|
|
1617
|
+
return json(200, await this.userJsonWithRelations(current, query));
|
|
1618
|
+
}
|
|
1619
|
+
transactionParams.push(current.id);
|
|
1620
|
+
const updatedResult = await query(`update auth.users set ${transactionSets.join(", ")}, updated_at = now() where id = $${transactionParams.length} returning *`, transactionParams);
|
|
1621
|
+
const updated = updatedResult.rows[0];
|
|
1622
|
+
if (!updated)
|
|
1623
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1624
|
+
if (requestedEmail)
|
|
1625
|
+
await this.ensureEmailIdentity(updated, query);
|
|
1626
|
+
return json(200, await this.userJsonWithRelations(updated, query));
|
|
1627
|
+
});
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
1630
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1631
|
+
}
|
|
1632
|
+
throw error;
|
|
1562
1633
|
}
|
|
1563
|
-
|
|
1564
|
-
const payload = await this.db.transaction(async (query) => {
|
|
1565
|
-
const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
|
|
1566
|
-
const updated = res.rows[0];
|
|
1567
|
-
if (body.email)
|
|
1568
|
-
await this.ensureEmailIdentity(updated, query);
|
|
1569
|
-
return this.userJsonWithRelations(updated, query);
|
|
1570
|
-
});
|
|
1571
|
-
return json(200, payload);
|
|
1634
|
+
return response;
|
|
1572
1635
|
}
|
|
1573
1636
|
async logout(req, url) {
|
|
1574
1637
|
const claims = await this.claimsFromBearer(req);
|
|
@@ -1594,24 +1657,39 @@ class AuthHandler {
|
|
|
1594
1657
|
}
|
|
1595
1658
|
async issueToken(email, tokenType, createUser, flavor = "login") {
|
|
1596
1659
|
const normalized = email.toLowerCase().trim();
|
|
1597
|
-
let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
|
|
1598
|
-
let user = res.rows[0];
|
|
1599
|
-
if (!user) {
|
|
1600
|
-
if (!createUser)
|
|
1601
|
-
return json(200, {});
|
|
1602
|
-
if (this.settings.disableSignup)
|
|
1603
|
-
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1604
|
-
res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1605
|
-
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1606
|
-
returning *`, [normalized]);
|
|
1607
|
-
user = res.rows[0];
|
|
1608
|
-
}
|
|
1609
|
-
const code = randomOtp(this.settings.otpLength);
|
|
1610
|
-
const linkToken = randomToken(24);
|
|
1611
1660
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1612
|
-
await this.db.
|
|
1613
|
-
|
|
1614
|
-
|
|
1661
|
+
const prepared = await this.db.transaction(async (query) => {
|
|
1662
|
+
let user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
1663
|
+
if (!user) {
|
|
1664
|
+
if (!createUser)
|
|
1665
|
+
return { kind: "unknown" };
|
|
1666
|
+
if (this.settings.disableSignup)
|
|
1667
|
+
return { kind: "signup_disabled" };
|
|
1668
|
+
const inserted = await query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1669
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1670
|
+
on conflict (email) do nothing
|
|
1671
|
+
returning *`, [normalized]);
|
|
1672
|
+
user = inserted.rows[0];
|
|
1673
|
+
if (!user) {
|
|
1674
|
+
user = (await query(`select * from auth.users where lower(email) = $1 for update`, [normalized])).rows[0];
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (!user)
|
|
1678
|
+
return { kind: "unknown" };
|
|
1679
|
+
const code = randomOtp(this.settings.otpLength);
|
|
1680
|
+
const linkToken = randomToken(24);
|
|
1681
|
+
const tokenTypes = tokenType === "otp" ? ["otp", "magiclink"] : [tokenType];
|
|
1682
|
+
await query(`delete from auth.one_time_tokens where lower(email) = $1 and token_type = any($2::text[])`, [normalized, tokenTypes]);
|
|
1683
|
+
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
1684
|
+
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]);
|
|
1685
|
+
return { kind: "issued", user, code, linkToken };
|
|
1686
|
+
});
|
|
1687
|
+
if (prepared.kind === "unknown")
|
|
1688
|
+
return json(200, {});
|
|
1689
|
+
if (prepared.kind === "signup_disabled") {
|
|
1690
|
+
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1691
|
+
}
|
|
1692
|
+
const { user, code, linkToken } = prepared;
|
|
1615
1693
|
const kind = tokenType === "otp" ? "magiclink" : tokenType;
|
|
1616
1694
|
const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
|
|
1617
1695
|
await this.config.mailer.send({
|
|
@@ -1640,9 +1718,10 @@ Or sign in with this link: ${link}`
|
|
|
1640
1718
|
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
1641
1719
|
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1642
1720
|
}
|
|
1643
|
-
|
|
1721
|
+
const email = normalizeEmailInput(body.email);
|
|
1722
|
+
if (!email)
|
|
1644
1723
|
return authError(400, "validation_failed", "email or phone is required");
|
|
1645
|
-
return this.limit("otp", req) ?? this.issueToken(
|
|
1724
|
+
return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
|
|
1646
1725
|
}
|
|
1647
1726
|
async issuePhoneToken(phone, createUser, metadata) {
|
|
1648
1727
|
const sender = this.config.smsSender;
|
|
@@ -1796,9 +1875,10 @@ Or sign in with this link: ${link}`
|
|
|
1796
1875
|
}
|
|
1797
1876
|
async sendRecovery(req) {
|
|
1798
1877
|
const body = await req.json().catch(() => ({}));
|
|
1799
|
-
|
|
1878
|
+
const email = normalizeEmailInput(body.email);
|
|
1879
|
+
if (!email)
|
|
1800
1880
|
return authError(400, "validation_failed", "email is required");
|
|
1801
|
-
return this.issueToken(
|
|
1881
|
+
return this.issueToken(email, "recovery", false);
|
|
1802
1882
|
}
|
|
1803
1883
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1804
1884
|
async redeem(token, types, email) {
|
|
@@ -1822,8 +1902,8 @@ Or sign in with this link: ${link}`
|
|
|
1822
1902
|
return claimedToken ? this.confirmEmailTokenUser(query, claimedToken) : null;
|
|
1823
1903
|
}
|
|
1824
1904
|
async lockAndDeleteEmailToken(query, candidate, redemption) {
|
|
1825
|
-
const eligible = await query(`select id from auth.users where id = $1 and
|
|
1826
|
-
and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id]);
|
|
1905
|
+
const eligible = await query(`select id from auth.users where id = $1 and lower(email) = lower($2)
|
|
1906
|
+
and deleted_at is null and (banned_until is null or banned_until <= now()) for update`, [candidate.user_id, candidate.email]);
|
|
1827
1907
|
if (eligible.rows.length === 0)
|
|
1828
1908
|
return null;
|
|
1829
1909
|
const claimedTokens = await query(`delete from auth.one_time_tokens
|
|
@@ -1833,15 +1913,19 @@ Or sign in with this link: ${link}`
|
|
|
1833
1913
|
return claimedTokens.rows[0] ?? null;
|
|
1834
1914
|
}
|
|
1835
1915
|
async confirmEmailTokenUser(query, claimedToken) {
|
|
1836
|
-
await query(`delete from auth.one_time_tokens where email = $1`, [claimedToken.email]);
|
|
1916
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1)`, [claimedToken.email]);
|
|
1837
1917
|
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1838
1918
|
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1839
|
-
|
|
1919
|
+
const user = users.rows[0];
|
|
1920
|
+
if (!user)
|
|
1921
|
+
return null;
|
|
1922
|
+
await this.ensureEmailIdentity(user, query);
|
|
1923
|
+
return user;
|
|
1840
1924
|
}
|
|
1841
1925
|
async findEmailTokenCandidate(query, redemption) {
|
|
1842
1926
|
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1843
1927
|
where token = $1 and token_type = any($2::text[])
|
|
1844
|
-
and ($3::text is null or email = $3) and expires_at > now()
|
|
1928
|
+
and ($3::text is null or lower(email) = lower($3)) and expires_at > now()
|
|
1845
1929
|
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1846
1930
|
return candidates.rows[0] ?? null;
|
|
1847
1931
|
}
|
|
@@ -1849,8 +1933,8 @@ Or sign in with this link: ${link}`
|
|
|
1849
1933
|
if (!redemption.email)
|
|
1850
1934
|
return;
|
|
1851
1935
|
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1852
|
-
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1853
|
-
await query(`delete from auth.one_time_tokens where email = $1 and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1936
|
+
where lower(email) = lower($1) and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1937
|
+
await query(`delete from auth.one_time_tokens where lower(email) = lower($1) and attempts >= $2`, [redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1854
1938
|
}
|
|
1855
1939
|
async verifyToken(req) {
|
|
1856
1940
|
const body = await req.json().catch(() => ({}));
|
|
@@ -1886,11 +1970,10 @@ Or sign in with this link: ${link}`
|
|
|
1886
1970
|
async redeemPhoneOtp(phone, code) {
|
|
1887
1971
|
const tokenDigest = `hmac-sha256:v1:${await keyedDigest(this.config.jwtSecret, "supacloud-lite:phone-otp:v1", `${phone}\x00${code}`)}`;
|
|
1888
1972
|
return this.db.transaction(async (query) => {
|
|
1889
|
-
const
|
|
1973
|
+
const candidate = await query(`select user_id from auth.one_time_tokens
|
|
1890
1974
|
where phone = $1 and token_type = 'sms' and token = $2
|
|
1891
|
-
and expires_at > now() and attempts < $3
|
|
1892
|
-
|
|
1893
|
-
const userId = claimed.rows[0]?.user_id;
|
|
1975
|
+
and expires_at > now() and attempts < $3`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1976
|
+
const userId = candidate.rows[0]?.user_id;
|
|
1894
1977
|
if (!userId) {
|
|
1895
1978
|
const attempt = await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1896
1979
|
where phone = $1 and token_type = 'sms' and expires_at > now()
|
|
@@ -1902,6 +1985,22 @@ Or sign in with this link: ${link}`
|
|
|
1902
1985
|
await query(`delete from auth.one_time_tokens where phone = $1 and expires_at <= now()`, [phone]);
|
|
1903
1986
|
return null;
|
|
1904
1987
|
}
|
|
1988
|
+
const currentUser = await query(`select phone,
|
|
1989
|
+
deleted_at is null and (banned_until is null or banned_until <= now()) as auth_eligible
|
|
1990
|
+
from auth.users where id = $1 for update`, [userId]);
|
|
1991
|
+
const current = currentUser.rows[0];
|
|
1992
|
+
if (!current || current.phone !== phone)
|
|
1993
|
+
return null;
|
|
1994
|
+
if (!current.auth_eligible) {
|
|
1995
|
+
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
const claimed = await query(`delete from auth.one_time_tokens
|
|
1999
|
+
where phone = $1 and token_type = 'sms' and token = $2
|
|
2000
|
+
and expires_at > now() and attempts < $3 and user_id = $4
|
|
2001
|
+
returning user_id`, [phone, tokenDigest, AuthHandler.MAX_OTP_ATTEMPTS, userId]);
|
|
2002
|
+
if (claimed.rows.length === 0)
|
|
2003
|
+
return null;
|
|
1905
2004
|
await query(`delete from auth.one_time_tokens where phone = $1`, [phone]);
|
|
1906
2005
|
const users = await query(`update auth.users
|
|
1907
2006
|
set phone_confirmed_at = coalesce(phone_confirmed_at, now()), last_sign_in_at = now(), updated_at = now()
|
|
@@ -1966,14 +2065,18 @@ Or sign in with this link: ${link}`
|
|
|
1966
2065
|
}
|
|
1967
2066
|
if (path === "admin/users" && method === "POST") {
|
|
1968
2067
|
const body = await req.json().catch(() => ({}));
|
|
1969
|
-
|
|
2068
|
+
const email = normalizeEmailInput(body.email);
|
|
2069
|
+
if (!email)
|
|
1970
2070
|
return authError(400, "validation_failed", "email is required");
|
|
1971
|
-
const email = body.email;
|
|
1972
2071
|
const hashed = body.password ? await hashPassword(body.password) : null;
|
|
1973
2072
|
const created = await this.db.transaction(async (query) => {
|
|
2073
|
+
const existing = await query(`select id from auth.users where lower(email) = $1 for update`, [email.toLowerCase().trim()]);
|
|
2074
|
+
if (existing.rows.length > 0)
|
|
2075
|
+
return null;
|
|
1974
2076
|
const res = await query(`insert into auth.users
|
|
1975
2077
|
(aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
|
|
1976
2078
|
values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
|
|
2079
|
+
on conflict (email) do nothing
|
|
1977
2080
|
returning *`, [
|
|
1978
2081
|
email.toLowerCase().trim(),
|
|
1979
2082
|
hashed,
|
|
@@ -1982,9 +2085,13 @@ Or sign in with this link: ${link}`
|
|
|
1982
2085
|
JSON.stringify(body.user_metadata ?? {})
|
|
1983
2086
|
]);
|
|
1984
2087
|
const user = res.rows[0];
|
|
2088
|
+
if (!user)
|
|
2089
|
+
return null;
|
|
1985
2090
|
await this.ensureEmailIdentity(user, query);
|
|
1986
2091
|
return this.userJsonWithRelations(user, query);
|
|
1987
2092
|
});
|
|
2093
|
+
if (!created)
|
|
2094
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
1988
2095
|
return json(200, created);
|
|
1989
2096
|
}
|
|
1990
2097
|
if (idMatch && method === "GET") {
|
|
@@ -1997,8 +2104,11 @@ Or sign in with this link: ${link}`
|
|
|
1997
2104
|
const body = await req.json().catch(() => ({}));
|
|
1998
2105
|
const sets = [];
|
|
1999
2106
|
const params = [];
|
|
2000
|
-
|
|
2001
|
-
|
|
2107
|
+
const requestedEmail = body.email === undefined ? undefined : normalizeEmailInput(body.email);
|
|
2108
|
+
if (body.email !== undefined && !requestedEmail)
|
|
2109
|
+
return authError(400, "validation_failed", "email must not be empty");
|
|
2110
|
+
if (requestedEmail) {
|
|
2111
|
+
params.push(requestedEmail);
|
|
2002
2112
|
sets.push(`email = $${params.length}`);
|
|
2003
2113
|
}
|
|
2004
2114
|
if (typeof body.password === "string") {
|
|
@@ -2018,15 +2128,33 @@ Or sign in with this link: ${link}`
|
|
|
2018
2128
|
if (sets.length === 0)
|
|
2019
2129
|
return authError(400, "validation_failed", "nothing to update");
|
|
2020
2130
|
params.push(idMatch[1]);
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2131
|
+
let updated;
|
|
2132
|
+
let emailConflict = false;
|
|
2133
|
+
try {
|
|
2134
|
+
updated = await this.db.transaction(async (query) => {
|
|
2135
|
+
if (requestedEmail) {
|
|
2136
|
+
const clash = await query(`select id from auth.users where lower(email) = $1 and id <> $2`, [requestedEmail, idMatch[1]]);
|
|
2137
|
+
if (clash.rows.length > 0) {
|
|
2138
|
+
emailConflict = true;
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const res = await query(`update auth.users set ${sets.join(", ")}, updated_at = now() where id = $${params.length} returning *`, params);
|
|
2143
|
+
const user = res.rows[0];
|
|
2144
|
+
if (!user)
|
|
2145
|
+
return null;
|
|
2146
|
+
if (requestedEmail)
|
|
2147
|
+
await this.ensureEmailIdentity(user, query);
|
|
2148
|
+
return this.userJsonWithRelations(user, query);
|
|
2149
|
+
});
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
if (databaseDiagnosticCode(error) === "23505" && requestedEmail) {
|
|
2152
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2153
|
+
}
|
|
2154
|
+
throw error;
|
|
2155
|
+
}
|
|
2156
|
+
if (emailConflict)
|
|
2157
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2030
2158
|
if (!updated)
|
|
2031
2159
|
return authError(404, "user_not_found", "User not found");
|
|
2032
2160
|
return json(200, updated);
|
|
@@ -2041,26 +2169,30 @@ Or sign in with this link: ${link}`
|
|
|
2041
2169
|
if (body.type !== "magiclink") {
|
|
2042
2170
|
return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
|
|
2043
2171
|
}
|
|
2044
|
-
|
|
2172
|
+
const email = normalizeEmailInput(body.email);
|
|
2173
|
+
if (!email)
|
|
2045
2174
|
return authError(400, "validation_failed", "email is required");
|
|
2046
|
-
const email = body.email.toLowerCase().trim();
|
|
2047
2175
|
const emailOtp = randomOtp(this.settings.otpLength);
|
|
2048
2176
|
const hashedToken = await sha256Hex(randomToken(32));
|
|
2049
2177
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
2050
2178
|
const requestUrl = new URL(req.url);
|
|
2051
2179
|
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
2052
2180
|
const user = await this.db.transaction(async (query) => {
|
|
2053
|
-
const
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2181
|
+
const existing = await query(`select * from auth.users where lower(email) = $1 for update`, [email]);
|
|
2182
|
+
let linkedUser = existing.rows[0];
|
|
2183
|
+
if (!linkedUser) {
|
|
2184
|
+
const result = await query(`insert into auth.users
|
|
2185
|
+
(aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
2186
|
+
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', $2::jsonb)
|
|
2187
|
+
on conflict (email) do update set email = excluded.email
|
|
2188
|
+
returning *`, [email, JSON.stringify(body.data ?? {})]);
|
|
2189
|
+
linkedUser = result.rows[0];
|
|
2190
|
+
}
|
|
2059
2191
|
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
2060
2192
|
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
2061
2193
|
on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
|
|
2062
2194
|
await query(`delete from auth.one_time_tokens
|
|
2063
|
-
where email = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
2195
|
+
where lower(email) = $1 and token_type = any($2::text[])`, [email, "{otp,magiclink}"]);
|
|
2064
2196
|
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
2065
2197
|
values ($1, $2, 'otp', $3, now() + $5::interval),
|
|
2066
2198
|
($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
|
|
@@ -2152,17 +2284,7 @@ Or sign in with this link: ${link}`
|
|
|
2152
2284
|
if (!this.settings.totpEnrollEnabled) {
|
|
2153
2285
|
return authError(422, "mfa_totp_enroll_disabled", "TOTP enrollment is disabled");
|
|
2154
2286
|
}
|
|
2155
|
-
const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
|
|
2156
|
-
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2157
|
-
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2158
|
-
}
|
|
2159
2287
|
const friendlyName = body.friendly_name ?? null;
|
|
2160
|
-
if (friendlyName) {
|
|
2161
|
-
const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
|
|
2162
|
-
if (dup.rows.length > 0) {
|
|
2163
|
-
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
2288
|
const secret = generateTotpSecret();
|
|
2167
2289
|
let issuer = body.issuer;
|
|
2168
2290
|
if (!issuer) {
|
|
@@ -2172,16 +2294,33 @@ Or sign in with this link: ${link}`
|
|
|
2172
2294
|
issuer = "supacloud-lite";
|
|
2173
2295
|
}
|
|
2174
2296
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2297
|
+
return this.db.transaction(async (query) => {
|
|
2298
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2299
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2300
|
+
const current = currentResult.rows[0];
|
|
2301
|
+
if (!current)
|
|
2302
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2303
|
+
const uri = otpauthUri({ secret, account: current.email || current.id, issuer });
|
|
2304
|
+
const enrolled = await query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [current.id]);
|
|
2305
|
+
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2306
|
+
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2307
|
+
}
|
|
2308
|
+
if (friendlyName) {
|
|
2309
|
+
const dup = await query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [current.id, friendlyName]);
|
|
2310
|
+
if (dup.rows.length > 0) {
|
|
2311
|
+
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
const ins = await query(`insert into auth.mfa_factors (user_id, friendly_name, factor_type, status, secret)
|
|
2315
|
+
values ($1, $2, 'totp', 'unverified', $3) returning id`, [current.id, friendlyName, secret]);
|
|
2316
|
+
const id = ins.rows[0].id;
|
|
2317
|
+
return json(200, {
|
|
2318
|
+
id,
|
|
2319
|
+
type: "totp",
|
|
2320
|
+
friendly_name: friendlyName,
|
|
2321
|
+
status: "unverified",
|
|
2322
|
+
totp: { qr_code: qrSvgDataUri(uri), secret, uri }
|
|
2323
|
+
});
|
|
2185
2324
|
});
|
|
2186
2325
|
}
|
|
2187
2326
|
async challengeFactor(req, factorId) {
|
|
@@ -2191,22 +2330,31 @@ Or sign in with this link: ${link}`
|
|
|
2191
2330
|
if (!this.settings.totpVerifyEnabled) {
|
|
2192
2331
|
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2193
2332
|
}
|
|
2194
|
-
const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2195
|
-
if (fr.rows.length === 0)
|
|
2196
|
-
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2197
2333
|
const expiresAt = new Date(Date.now() + 300000).toISOString();
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2334
|
+
return this.db.transaction(async (query) => {
|
|
2335
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2336
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2337
|
+
if (!currentResult.rows[0])
|
|
2338
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2339
|
+
const fr = await query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2340
|
+
if (fr.rows.length === 0)
|
|
2341
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2342
|
+
const ins = await query(`insert into auth.mfa_challenges (factor_id, expires_at) values ($1, $2) returning id, expires_at`, [factorId, expiresAt]);
|
|
2343
|
+
const c = ins.rows[0];
|
|
2344
|
+
return json(200, {
|
|
2345
|
+
id: c.id,
|
|
2346
|
+
type: "totp",
|
|
2347
|
+
expires_at: Math.floor(new Date(c.expires_at).getTime() / 1000)
|
|
2348
|
+
});
|
|
2204
2349
|
});
|
|
2205
2350
|
}
|
|
2206
2351
|
async verifyFactor(req, factorId) {
|
|
2207
2352
|
const user = await this.userFromBearer(req);
|
|
2208
2353
|
if (!user)
|
|
2209
2354
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2355
|
+
if (!this.settings.totpVerifyEnabled) {
|
|
2356
|
+
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2357
|
+
}
|
|
2210
2358
|
const body = await req.json().catch(() => ({}));
|
|
2211
2359
|
const fr = await this.db.query(`select id, secret, status from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2212
2360
|
const factor = fr.rows[0];
|
|
@@ -2231,12 +2379,17 @@ Or sign in with this link: ${link}`
|
|
|
2231
2379
|
const currentUser = currentUsers.rows[0];
|
|
2232
2380
|
if (!currentUser)
|
|
2233
2381
|
return null;
|
|
2382
|
+
const currentFactors = await query(`select id, secret, status from auth.mfa_factors
|
|
2383
|
+
where id = $1 and user_id = $2 for update`, [factorId, user.id]);
|
|
2384
|
+
const currentFactor = currentFactors.rows[0];
|
|
2385
|
+
if (!currentFactor || !await verifyTotp(currentFactor.secret, body.code ?? ""))
|
|
2386
|
+
return null;
|
|
2234
2387
|
const claimed = await query(`update auth.mfa_challenges set verified_at = now()
|
|
2235
2388
|
where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
|
|
2236
2389
|
returning id`, [challenge.id, factorId]);
|
|
2237
2390
|
if (claimed.rows.length === 0)
|
|
2238
2391
|
return null;
|
|
2239
|
-
if (
|
|
2392
|
+
if (currentFactor.status !== "verified") {
|
|
2240
2393
|
await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
|
|
2241
2394
|
}
|
|
2242
2395
|
return this.sessionFor(currentUser, undefined, {
|
|
@@ -2256,10 +2409,16 @@ Or sign in with this link: ${link}`
|
|
|
2256
2409
|
const user = await this.userFromBearer(req);
|
|
2257
2410
|
if (!user)
|
|
2258
2411
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2412
|
+
return this.db.transaction(async (query) => {
|
|
2413
|
+
const currentResult = await query(`select * from auth.users where id = $1 and deleted_at is null
|
|
2414
|
+
and (banned_until is null or banned_until <= now()) for update`, [user.id]);
|
|
2415
|
+
if (!currentResult.rows[0])
|
|
2416
|
+
return authError(401, "no_authorization", "Invalid or expired token");
|
|
2417
|
+
const del = await query(`delete from auth.mfa_factors where id = $1 and user_id = $2 returning id`, [factorId, user.id]);
|
|
2418
|
+
if (del.rows.length === 0)
|
|
2419
|
+
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2420
|
+
return json(200, { id: factorId });
|
|
2421
|
+
});
|
|
2263
2422
|
}
|
|
2264
2423
|
async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
2265
2424
|
const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
|
|
@@ -9232,7 +9391,8 @@ function applyResize(image, metadata, options) {
|
|
|
9232
9391
|
async function transformImage(source, options, knownSourceSize, configuredLimits = {}) {
|
|
9233
9392
|
const limits = resolveImageTransformLimits(configuredLimits);
|
|
9234
9393
|
const actualSourceSize = source instanceof Uint8Array ? source.byteLength : await Promise.resolve(source.size);
|
|
9235
|
-
const
|
|
9394
|
+
const declaredSourceSize = typeof knownSourceSize === "number" && Number.isFinite(knownSourceSize) && knownSourceSize >= 0 ? knownSourceSize : 0;
|
|
9395
|
+
const sourceSize = Math.max(declaredSourceSize, actualSourceSize);
|
|
9236
9396
|
if (sourceSize > limits.maxInputBytes) {
|
|
9237
9397
|
return imageTransformInputTooLarge(limits.maxInputBytes);
|
|
9238
9398
|
}
|
|
@@ -9458,7 +9618,7 @@ function isRenderableActiveType(contentType) {
|
|
|
9458
9618
|
return t === "text/html" || t === "application/xhtml+xml" || t === "image/svg+xml" || t === "application/xml" || t === "text/xml";
|
|
9459
9619
|
}
|
|
9460
9620
|
var TUS_VERSION = "1.0.0";
|
|
9461
|
-
var DEFAULT_FILE_SIZE_LIMIT =
|
|
9621
|
+
var DEFAULT_FILE_SIZE_LIMIT = 500 * 1024 * 1024;
|
|
9462
9622
|
var MULTIPART_OVERHEAD_LIMIT = 1024 * 1024;
|
|
9463
9623
|
var MAX_CONCURRENT_TUS_UPLOADS = 4;
|
|
9464
9624
|
var MAX_COMPLETED_TUS_UPLOADS = 64;
|