@supacloud/lite 0.31.3 → 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 +13 -0
- package/dist/cli.js +299 -167
- package/dist/index.js +299 -167
- package/dist/runtime/auth/handler.d.ts.map +1 -1
- package/dist/runtime/auth/oauth.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,7 +1571,11 @@ class AuthHandler {
|
|
|
1529
1571
|
const body = await req.json().catch(() => ({}));
|
|
1530
1572
|
const sets = [];
|
|
1531
1573
|
const params = [];
|
|
1532
|
-
|
|
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");
|
|
1577
|
+
}
|
|
1578
|
+
if (requestedEmail && !this.settings.autoconfirm) {
|
|
1533
1579
|
return authError(501, "email_change_confirmation_not_supported", "Email changes that require confirmation are not supported by SupaCloud Lite V1");
|
|
1534
1580
|
}
|
|
1535
1581
|
if (body.password) {
|
|
@@ -1543,40 +1589,48 @@ class AuthHandler {
|
|
|
1543
1589
|
params.push(JSON.stringify(body.data));
|
|
1544
1590
|
sets.push(`raw_user_meta_data = coalesce(raw_user_meta_data, '{}'::jsonb) || $${params.length}::jsonb`);
|
|
1545
1591
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
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()`);
|
|
1560
1611
|
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
transactionSets.
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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");
|
|
1570
1631
|
}
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
const updated = updatedResult.rows[0];
|
|
1574
|
-
if (!updated)
|
|
1575
|
-
return authError(401, "no_authorization", "Invalid or expired token");
|
|
1576
|
-
if (body.email)
|
|
1577
|
-
await this.ensureEmailIdentity(updated, query);
|
|
1578
|
-
return json(200, await this.userJsonWithRelations(updated, query));
|
|
1579
|
-
});
|
|
1632
|
+
throw error;
|
|
1633
|
+
}
|
|
1580
1634
|
return response;
|
|
1581
1635
|
}
|
|
1582
1636
|
async logout(req, url) {
|
|
@@ -1603,24 +1657,39 @@ class AuthHandler {
|
|
|
1603
1657
|
}
|
|
1604
1658
|
async issueToken(email, tokenType, createUser, flavor = "login") {
|
|
1605
1659
|
const normalized = email.toLowerCase().trim();
|
|
1606
|
-
let res = await this.db.query(`select * from auth.users where email = $1`, [normalized]);
|
|
1607
|
-
let user = res.rows[0];
|
|
1608
|
-
if (!user) {
|
|
1609
|
-
if (!createUser)
|
|
1610
|
-
return json(200, {});
|
|
1611
|
-
if (this.settings.disableSignup)
|
|
1612
|
-
return authError(422, "signup_disabled", "Signups not allowed for this instance");
|
|
1613
|
-
res = await this.db.query(`insert into auth.users (aud, role, email, raw_app_meta_data, raw_user_meta_data)
|
|
1614
|
-
values ('authenticated', 'authenticated', $1, '{"provider":"email","providers":["email"]}', '{}')
|
|
1615
|
-
returning *`, [normalized]);
|
|
1616
|
-
user = res.rows[0];
|
|
1617
|
-
}
|
|
1618
|
-
const code = randomOtp(this.settings.otpLength);
|
|
1619
|
-
const linkToken = randomToken(24);
|
|
1620
1660
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
1621
|
-
await this.db.
|
|
1622
|
-
|
|
1623
|
-
|
|
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;
|
|
1624
1693
|
const kind = tokenType === "otp" ? "magiclink" : tokenType;
|
|
1625
1694
|
const link = `${this.config.apiUrl}/auth/v1/verify?token=${linkToken}&type=${kind}`;
|
|
1626
1695
|
await this.config.mailer.send({
|
|
@@ -1649,9 +1718,10 @@ Or sign in with this link: ${link}`
|
|
|
1649
1718
|
return authError(400, "validation_failed", "phone must be a valid E.164 number");
|
|
1650
1719
|
return this.limit("sms", req) ?? this.issuePhoneToken(phone, body.create_user !== false, recordValue(body.data));
|
|
1651
1720
|
}
|
|
1652
|
-
|
|
1721
|
+
const email = normalizeEmailInput(body.email);
|
|
1722
|
+
if (!email)
|
|
1653
1723
|
return authError(400, "validation_failed", "email or phone is required");
|
|
1654
|
-
return this.limit("otp", req) ?? this.issueToken(
|
|
1724
|
+
return this.limit("otp", req) ?? this.issueToken(email, "otp", body.create_user !== false);
|
|
1655
1725
|
}
|
|
1656
1726
|
async issuePhoneToken(phone, createUser, metadata) {
|
|
1657
1727
|
const sender = this.config.smsSender;
|
|
@@ -1805,9 +1875,10 @@ Or sign in with this link: ${link}`
|
|
|
1805
1875
|
}
|
|
1806
1876
|
async sendRecovery(req) {
|
|
1807
1877
|
const body = await req.json().catch(() => ({}));
|
|
1808
|
-
|
|
1878
|
+
const email = normalizeEmailInput(body.email);
|
|
1879
|
+
if (!email)
|
|
1809
1880
|
return authError(400, "validation_failed", "email is required");
|
|
1810
|
-
return this.issueToken(
|
|
1881
|
+
return this.issueToken(email, "recovery", false);
|
|
1811
1882
|
}
|
|
1812
1883
|
static MAX_OTP_ATTEMPTS = 5;
|
|
1813
1884
|
async redeem(token, types, email) {
|
|
@@ -1842,15 +1913,19 @@ Or sign in with this link: ${link}`
|
|
|
1842
1913
|
return claimedTokens.rows[0] ?? null;
|
|
1843
1914
|
}
|
|
1844
1915
|
async confirmEmailTokenUser(query, claimedToken) {
|
|
1845
|
-
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]);
|
|
1846
1917
|
const users = await query(`update auth.users set email_confirmed_at = coalesce(email_confirmed_at, now()), last_sign_in_at = now()
|
|
1847
1918
|
where id = $1 returning *`, [claimedToken.user_id]);
|
|
1848
|
-
|
|
1919
|
+
const user = users.rows[0];
|
|
1920
|
+
if (!user)
|
|
1921
|
+
return null;
|
|
1922
|
+
await this.ensureEmailIdentity(user, query);
|
|
1923
|
+
return user;
|
|
1849
1924
|
}
|
|
1850
1925
|
async findEmailTokenCandidate(query, redemption) {
|
|
1851
1926
|
const candidates = await query(`select id, user_id, email from auth.one_time_tokens
|
|
1852
1927
|
where token = $1 and token_type = any($2::text[])
|
|
1853
|
-
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()
|
|
1854
1929
|
and attempts < $4`, [redemption.token, redemption.tokenTypes, redemption.email, AuthHandler.MAX_OTP_ATTEMPTS]);
|
|
1855
1930
|
return candidates.rows[0] ?? null;
|
|
1856
1931
|
}
|
|
@@ -1858,8 +1933,8 @@ Or sign in with this link: ${link}`
|
|
|
1858
1933
|
if (!redemption.email)
|
|
1859
1934
|
return;
|
|
1860
1935
|
await query(`update auth.one_time_tokens set attempts = attempts + 1
|
|
1861
|
-
where email = $1 and token_type = any($2::text[]) and expires_at > now()`, [redemption.email, redemption.tokenTypes]);
|
|
1862
|
-
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]);
|
|
1863
1938
|
}
|
|
1864
1939
|
async verifyToken(req) {
|
|
1865
1940
|
const body = await req.json().catch(() => ({}));
|
|
@@ -1990,14 +2065,18 @@ Or sign in with this link: ${link}`
|
|
|
1990
2065
|
}
|
|
1991
2066
|
if (path === "admin/users" && method === "POST") {
|
|
1992
2067
|
const body = await req.json().catch(() => ({}));
|
|
1993
|
-
|
|
2068
|
+
const email = normalizeEmailInput(body.email);
|
|
2069
|
+
if (!email)
|
|
1994
2070
|
return authError(400, "validation_failed", "email is required");
|
|
1995
|
-
const email = body.email;
|
|
1996
2071
|
const hashed = body.password ? await hashPassword(body.password) : null;
|
|
1997
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;
|
|
1998
2076
|
const res = await query(`insert into auth.users
|
|
1999
2077
|
(aud, role, email, encrypted_password, email_confirmed_at, raw_app_meta_data, raw_user_meta_data)
|
|
2000
2078
|
values ('authenticated', 'authenticated', $1, $2, case when $3 then now() else null end, $4, $5)
|
|
2079
|
+
on conflict (email) do nothing
|
|
2001
2080
|
returning *`, [
|
|
2002
2081
|
email.toLowerCase().trim(),
|
|
2003
2082
|
hashed,
|
|
@@ -2006,9 +2085,13 @@ Or sign in with this link: ${link}`
|
|
|
2006
2085
|
JSON.stringify(body.user_metadata ?? {})
|
|
2007
2086
|
]);
|
|
2008
2087
|
const user = res.rows[0];
|
|
2088
|
+
if (!user)
|
|
2089
|
+
return null;
|
|
2009
2090
|
await this.ensureEmailIdentity(user, query);
|
|
2010
2091
|
return this.userJsonWithRelations(user, query);
|
|
2011
2092
|
});
|
|
2093
|
+
if (!created)
|
|
2094
|
+
return authError(422, "email_exists", "A user with this email address has already been registered");
|
|
2012
2095
|
return json(200, created);
|
|
2013
2096
|
}
|
|
2014
2097
|
if (idMatch && method === "GET") {
|
|
@@ -2021,8 +2104,11 @@ Or sign in with this link: ${link}`
|
|
|
2021
2104
|
const body = await req.json().catch(() => ({}));
|
|
2022
2105
|
const sets = [];
|
|
2023
2106
|
const params = [];
|
|
2024
|
-
|
|
2025
|
-
|
|
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);
|
|
2026
2112
|
sets.push(`email = $${params.length}`);
|
|
2027
2113
|
}
|
|
2028
2114
|
if (typeof body.password === "string") {
|
|
@@ -2042,15 +2128,33 @@ Or sign in with this link: ${link}`
|
|
|
2042
2128
|
if (sets.length === 0)
|
|
2043
2129
|
return authError(400, "validation_failed", "nothing to update");
|
|
2044
2130
|
params.push(idMatch[1]);
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
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");
|
|
2054
2158
|
if (!updated)
|
|
2055
2159
|
return authError(404, "user_not_found", "User not found");
|
|
2056
2160
|
return json(200, updated);
|
|
@@ -2065,26 +2169,30 @@ Or sign in with this link: ${link}`
|
|
|
2065
2169
|
if (body.type !== "magiclink") {
|
|
2066
2170
|
return authError(422, "unsupported_link_type", "Only magiclink generation is supported");
|
|
2067
2171
|
}
|
|
2068
|
-
|
|
2172
|
+
const email = normalizeEmailInput(body.email);
|
|
2173
|
+
if (!email)
|
|
2069
2174
|
return authError(400, "validation_failed", "email is required");
|
|
2070
|
-
const email = body.email.toLowerCase().trim();
|
|
2071
2175
|
const emailOtp = randomOtp(this.settings.otpLength);
|
|
2072
2176
|
const hashedToken = await sha256Hex(randomToken(32));
|
|
2073
2177
|
const expiry = `${this.settings.otpExpirySeconds} seconds`;
|
|
2074
2178
|
const requestUrl = new URL(req.url);
|
|
2075
2179
|
const redirectTo = resolveRedirect(requestUrl.searchParams.get("redirect_to") ?? body.redirect_to, this.config.siteUrl, this.config.uriAllowList, this.config.enforceRedirectAllowList);
|
|
2076
2180
|
const user = await this.db.transaction(async (query) => {
|
|
2077
|
-
const
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
+
}
|
|
2083
2191
|
await query(`insert into auth.identities (user_id, provider, provider_id, identity_data)
|
|
2084
2192
|
values ($1::uuid, 'email', $1::text, $2::jsonb)
|
|
2085
2193
|
on conflict (provider, provider_id) do nothing`, [linkedUser.id, JSON.stringify({ sub: linkedUser.id, email })]);
|
|
2086
2194
|
await query(`delete from auth.one_time_tokens
|
|
2087
|
-
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}"]);
|
|
2088
2196
|
await query(`insert into auth.one_time_tokens (user_id, email, token_type, token, expires_at)
|
|
2089
2197
|
values ($1, $2, 'otp', $3, now() + $5::interval),
|
|
2090
2198
|
($1, $2, 'magiclink', $4, now() + $5::interval)`, [linkedUser.id, email, emailOtp, hashedToken, expiry]);
|
|
@@ -2176,17 +2284,7 @@ Or sign in with this link: ${link}`
|
|
|
2176
2284
|
if (!this.settings.totpEnrollEnabled) {
|
|
2177
2285
|
return authError(422, "mfa_totp_enroll_disabled", "TOTP enrollment is disabled");
|
|
2178
2286
|
}
|
|
2179
|
-
const enrolled = await this.db.query(`select count(*)::int as n from auth.mfa_factors where user_id = $1`, [user.id]);
|
|
2180
|
-
if (enrolled.rows[0].n >= this.settings.maxEnrolledFactors) {
|
|
2181
|
-
return authError(422, "too_many_enrolled_mfa_factors", "Maximum number of enrolled MFA factors reached");
|
|
2182
|
-
}
|
|
2183
2287
|
const friendlyName = body.friendly_name ?? null;
|
|
2184
|
-
if (friendlyName) {
|
|
2185
|
-
const dup = await this.db.query(`select 1 from auth.mfa_factors where user_id = $1 and friendly_name = $2`, [user.id, friendlyName]);
|
|
2186
|
-
if (dup.rows.length > 0) {
|
|
2187
|
-
return authError(422, "mfa_factor_name_conflict", "A factor with this friendly name already exists");
|
|
2188
|
-
}
|
|
2189
|
-
}
|
|
2190
2288
|
const secret = generateTotpSecret();
|
|
2191
2289
|
let issuer = body.issuer;
|
|
2192
2290
|
if (!issuer) {
|
|
@@ -2196,16 +2294,33 @@ Or sign in with this link: ${link}`
|
|
|
2196
2294
|
issuer = "supacloud-lite";
|
|
2197
2295
|
}
|
|
2198
2296
|
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
+
});
|
|
2209
2324
|
});
|
|
2210
2325
|
}
|
|
2211
2326
|
async challengeFactor(req, factorId) {
|
|
@@ -2215,16 +2330,22 @@ Or sign in with this link: ${link}`
|
|
|
2215
2330
|
if (!this.settings.totpVerifyEnabled) {
|
|
2216
2331
|
return authError(422, "mfa_totp_verify_disabled", "TOTP verification is disabled");
|
|
2217
2332
|
}
|
|
2218
|
-
const fr = await this.db.query(`select id from auth.mfa_factors where id = $1 and user_id = $2`, [factorId, user.id]);
|
|
2219
|
-
if (fr.rows.length === 0)
|
|
2220
|
-
return authError(404, "mfa_factor_not_found", "MFA factor not found");
|
|
2221
2333
|
const expiresAt = new Date(Date.now() + 300000).toISOString();
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
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
|
+
});
|
|
2228
2349
|
});
|
|
2229
2350
|
}
|
|
2230
2351
|
async verifyFactor(req, factorId) {
|
|
@@ -2258,12 +2379,17 @@ Or sign in with this link: ${link}`
|
|
|
2258
2379
|
const currentUser = currentUsers.rows[0];
|
|
2259
2380
|
if (!currentUser)
|
|
2260
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;
|
|
2261
2387
|
const claimed = await query(`update auth.mfa_challenges set verified_at = now()
|
|
2262
2388
|
where id = $1 and factor_id = $2 and verified_at is null and expires_at >= now()
|
|
2263
2389
|
returning id`, [challenge.id, factorId]);
|
|
2264
2390
|
if (claimed.rows.length === 0)
|
|
2265
2391
|
return null;
|
|
2266
|
-
if (
|
|
2392
|
+
if (currentFactor.status !== "verified") {
|
|
2267
2393
|
await query(`update auth.mfa_factors set status = 'verified', updated_at = now() where id = $1`, [factorId]);
|
|
2268
2394
|
}
|
|
2269
2395
|
return this.sessionFor(currentUser, undefined, {
|
|
@@ -2283,10 +2409,16 @@ Or sign in with this link: ${link}`
|
|
|
2283
2409
|
const user = await this.userFromBearer(req);
|
|
2284
2410
|
if (!user)
|
|
2285
2411
|
return authError(401, "no_authorization", "This endpoint requires a Bearer token");
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
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
|
+
});
|
|
2290
2422
|
}
|
|
2291
2423
|
async getUserFactors(userId, query = (sql, params) => this.db.query(sql, params)) {
|
|
2292
2424
|
const res = await query(`select id, friendly_name, factor_type, status, created_at, updated_at
|