@luckystack/login 0.5.0 → 0.6.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/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  registerOAuthProviderFactory,
15
15
  registerOAuthProviders,
16
16
  registerUserAdapter
17
- } from "./chunk-K5Z5KKKT.js";
17
+ } from "./chunk-HJHCX2JO.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -29,7 +29,7 @@ import {
29
29
  revokeUserSessions,
30
30
  saveSession,
31
31
  sessionKeyFor
32
- } from "./chunk-W6C2J7AW.js";
32
+ } from "./chunk-5WBFVSMW.js";
33
33
 
34
34
  // src/index.ts
35
35
  import { registerSessionProvider } from "@luckystack/core";
@@ -141,7 +141,7 @@ var registerPostLoginRedirect = (resolver) => {
141
141
  var getPostLoginRedirect = () => activeResolver;
142
142
 
143
143
  // src/login.ts
144
- import { URLSearchParams } from "url";
144
+ import { URLSearchParams as URLSearchParams2 } from "url";
145
145
  import { tryCatch as tryCatch2, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue } from "@luckystack/core";
146
146
  import bcrypt from "bcryptjs";
147
147
  import { randomBytes, createHash, timingSafeEqual } from "crypto";
@@ -512,7 +512,7 @@ var resolveUploadedAvatar = async (userId) => {
512
512
  return avatarStat ? `${userId}.webp` : null;
513
513
  };
514
514
  var sanitizeUserForSession = (user) => {
515
- const { password: _password, ...safeUser } = user;
515
+ const { password: _password, totpSecret: _totpSecret, recoveryCodes: _recoveryCodes, ...safeUser } = user;
516
516
  return safeUser;
517
517
  };
518
518
  var emitLoginFailed = (payload) => {
@@ -645,6 +645,41 @@ var registerWithCredentials = async ({
645
645
  session: newUser
646
646
  };
647
647
  };
648
+ var twoFactorGate = null;
649
+ var registerTwoFactorGate = (gate) => {
650
+ twoFactorGate = gate;
651
+ };
652
+ var applyTwoFactorGate = async (user, context) => twoFactorGate ? twoFactorGate(user, context) : null;
653
+ var finalizeLogin = async (user, options) => {
654
+ const newToken = randomBytes(32).toString("hex");
655
+ const previousLogin = user.lastLogin ?? null;
656
+ const nowLogin = /* @__PURE__ */ new Date();
657
+ await tryCatch2(() => getUserAdapter().update(user.id, { lastLogin: nowLogin }));
658
+ const newUser = {
659
+ ...sanitizeUserForSession(user),
660
+ token: newToken,
661
+ lastLogin: nowLogin,
662
+ previousLogin
663
+ };
664
+ const uploadedAvatar = await resolveUploadedAvatar(newUser.id);
665
+ if (uploadedAvatar) {
666
+ newUser.avatar = uploadedAvatar;
667
+ }
668
+ const saved = await saveSession(newToken, newUser, true, { supersedeToken: options.supersedeToken });
669
+ if (!saved.ok) {
670
+ emitLoginFailed({ email: options.email, userId: newUser.id, provider: options.provider, reason: saved.errorCode, stage: "login", requesterIp: options.requesterIp });
671
+ return { status: false, reason: saved.errorCode };
672
+ }
673
+ await dispatchHook("postLogin", { userId: newUser.id, provider: options.provider, isNewUser: false, token: newToken });
674
+ if (options.email) {
675
+ void clearAuthFailures(options.email, options.requesterIp);
676
+ if (options.requesterIp) void clearAuthFailures(options.email);
677
+ }
678
+ if (isDevMode()) {
679
+ getLogger3().debug(`${options.provider} login success for user ${newUser.id}`);
680
+ }
681
+ return { status: true, reason: "login.loggedIn", newToken, session: newUser };
682
+ };
648
683
  var loginWithCredentialsCore = async ({ email, password }, options) => {
649
684
  const requesterIp = options?.requesterIp;
650
685
  const preLoginResult = await dispatchHook("preLogin", { email, provider: "credentials" });
@@ -686,32 +721,14 @@ var loginWithCredentialsCore = async ({ email, password }, options) => {
686
721
  await dispatchHook("loginFailed", { email, userId: findUserResponse.id, provider: "credentials", reason: "login.wrongPassword", stage: "login", requesterIp });
687
722
  return { status: false, reason: "login.wrongPassword" };
688
723
  }
689
- const newToken = randomBytes(32).toString("hex");
690
- const previousLogin = findUserResponse.lastLogin ?? null;
691
- const nowLogin = /* @__PURE__ */ new Date();
692
- await tryCatch2(() => userAdapter.update(findUserResponse.id, { lastLogin: nowLogin }));
693
- const newUser = {
694
- ...sanitizeUserForSession(findUserResponse),
695
- token: newToken,
696
- lastLogin: nowLogin,
697
- previousLogin
698
- };
699
- const uploadedAvatar = await resolveUploadedAvatar(newUser.id);
700
- if (uploadedAvatar) {
701
- newUser.avatar = uploadedAvatar;
702
- }
703
- const saved = await saveSession(newToken, newUser, true, { supersedeToken: options?.supersedeToken });
704
- if (!saved.ok) {
705
- emitLoginFailed({ email, userId: newUser.id, provider: "credentials", reason: saved.errorCode, stage: "login", requesterIp });
706
- return { status: false, reason: saved.errorCode };
707
- }
708
- await dispatchHook("postLogin", { userId: newUser.id, provider: "credentials", isNewUser: false, token: newToken });
709
- void clearAuthFailures(email, requesterIp);
710
- if (requesterIp) void clearAuthFailures(email);
711
- if (isDevMode()) {
712
- getLogger3().debug(`credentials login success for user ${newUser.id}`);
713
- }
714
- return { status: true, reason: "login.loggedIn", newToken, session: newUser };
724
+ const challenge = await applyTwoFactorGate(findUserResponse, { requesterIp });
725
+ if (challenge) return challenge;
726
+ return finalizeLogin(findUserResponse, {
727
+ provider: "credentials",
728
+ email,
729
+ supersedeToken: options?.supersedeToken,
730
+ requesterIp
731
+ });
715
732
  };
716
733
  var loginWithCredentials = async (params, options) => {
717
734
  const creds = normalizeCredentials(params);
@@ -777,7 +794,7 @@ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
777
794
  }
778
795
  return await response3.json();
779
796
  }
780
- const formParams = new URLSearchParams();
797
+ const formParams = new URLSearchParams2();
781
798
  formParams.append("client_id", provider.clientID);
782
799
  formParams.append("client_secret", provider.clientSecret);
783
800
  formParams.append("code", values.code);
@@ -1020,7 +1037,7 @@ var loginCallback = async (pathname, req, _res, options = {}) => {
1020
1037
  if (!providerResolution) return false;
1021
1038
  const { provider, providerName } = providerResolution;
1022
1039
  const queryString = (req.url ?? "").split("?")[1] ?? "";
1023
- const params = new URLSearchParams(queryString);
1040
+ const params = new URLSearchParams2(queryString);
1024
1041
  const code = params.get("code");
1025
1042
  const state = params.get("state");
1026
1043
  const cookieNonce = getCookieValue(req.headers.cookie, OAUTH_STATE_COOKIE_NAME) ?? "";
@@ -1238,6 +1255,500 @@ var sendEmailChangeConfirmation = async ({ userId, newEmail, userName, brand })
1238
1255
  return result.ok ? { ok: true, token } : { ok: false, reason: result.reason, token };
1239
1256
  };
1240
1257
 
1258
+ // src/twoFactor.ts
1259
+ import crypto3 from "crypto";
1260
+ import { acquireLease, dispatchHook as dispatchHook3, formatKey as formatKey5, getLogger as getLogger5, getProjectConfig as getProjectConfig9, getProjectName, redis as redis4, releaseLease, tryCatch as tryCatch3 } from "@luckystack/core";
1261
+
1262
+ // src/emailOtp.ts
1263
+ import crypto from "crypto";
1264
+ import { formatKey as formatKey4, redis as redis3 } from "@luckystack/core";
1265
+ var normalizeIdentity = (identity) => identity.trim().toLowerCase();
1266
+ var codeKey = (purpose, identity) => formatKey4("-emailcode", `${purpose}:${normalizeIdentity(identity)}`);
1267
+ var attemptsKey = (purpose, identity) => formatKey4("-emailcode-attempts", `${purpose}:${normalizeIdentity(identity)}`);
1268
+ var hashCode = (code) => crypto.createHash("sha256").update(code).digest("hex");
1269
+ var generateNumericCode = (digits) => String(crypto.randomInt(0, 10 ** digits)).padStart(digits, "0");
1270
+ var issueEmailCode = async ({ purpose, identity, ttlSeconds, digits }) => {
1271
+ const code = generateNumericCode(digits);
1272
+ await redis3.set(codeKey(purpose, identity), hashCode(code), "EX", ttlSeconds);
1273
+ await redis3.del(attemptsKey(purpose, identity));
1274
+ return code;
1275
+ };
1276
+ var verifyEmailCode = async ({ purpose, identity, code, maxAttempts }) => {
1277
+ const key = codeKey(purpose, identity);
1278
+ const storedHash = await redis3.get(key);
1279
+ if (!storedHash) return "expired";
1280
+ const counter = attemptsKey(purpose, identity);
1281
+ const attempts = await redis3.incr(counter);
1282
+ const remaining = await redis3.ttl(key);
1283
+ await redis3.expire(counter, remaining > 0 ? remaining : 600);
1284
+ if (attempts > maxAttempts) {
1285
+ await redis3.del(key);
1286
+ await redis3.del(counter);
1287
+ return "locked";
1288
+ }
1289
+ const submitted = Buffer.from(hashCode(code.trim()));
1290
+ const expected = Buffer.from(storedHash);
1291
+ const matches = submitted.length === expected.length && crypto.timingSafeEqual(submitted, expected);
1292
+ if (!matches) return "invalid";
1293
+ const deleted = await redis3.del(key);
1294
+ await redis3.del(counter);
1295
+ return deleted > 0 ? "valid" : "expired";
1296
+ };
1297
+ var clearEmailCode = async (purpose, identity) => {
1298
+ await redis3.del(codeKey(purpose, identity));
1299
+ await redis3.del(attemptsKey(purpose, identity));
1300
+ };
1301
+
1302
+ // src/totp.ts
1303
+ import crypto2 from "crypto";
1304
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
1305
+ var base32Encode = (input) => {
1306
+ let bits = 0;
1307
+ let value = 0;
1308
+ let output = "";
1309
+ for (const byte of input) {
1310
+ value = value << 8 | byte;
1311
+ bits += 8;
1312
+ while (bits >= 5) {
1313
+ output += BASE32_ALPHABET[value >>> bits - 5 & 31];
1314
+ bits -= 5;
1315
+ }
1316
+ }
1317
+ if (bits > 0) output += BASE32_ALPHABET[value << 5 - bits & 31];
1318
+ return output;
1319
+ };
1320
+ var base32Decode = (input) => {
1321
+ const normalized = input.toUpperCase().replaceAll(/[\s=]/g, "");
1322
+ if (normalized.length === 0) return null;
1323
+ let bits = 0;
1324
+ let value = 0;
1325
+ const bytes = [];
1326
+ for (const char of normalized) {
1327
+ const index = BASE32_ALPHABET.indexOf(char);
1328
+ if (index === -1) return null;
1329
+ value = value << 5 | index;
1330
+ bits += 5;
1331
+ if (bits >= 8) {
1332
+ bytes.push(value >>> bits - 8 & 255);
1333
+ bits -= 8;
1334
+ }
1335
+ }
1336
+ return Buffer.from(bytes);
1337
+ };
1338
+ var hotp = (key, counter, digits = 6) => {
1339
+ const counterBuffer = Buffer.alloc(8);
1340
+ counterBuffer.writeBigUInt64BE(BigInt(counter));
1341
+ const digest = crypto2.createHmac("sha1", key).update(counterBuffer).digest();
1342
+ const offset = digest.readUInt8(digest.length - 1) & 15;
1343
+ const binary = digest.readUInt32BE(offset) & 2147483647;
1344
+ return String(binary % 10 ** digits).padStart(digits, "0");
1345
+ };
1346
+ var TOTP_STEP_SECONDS = 30;
1347
+ var verifyTotp = ({ secret, code, window = 1, timestampMs }) => {
1348
+ const key = base32Decode(secret);
1349
+ const normalizedCode = code.trim();
1350
+ if (!key || key.length === 0 || !/^\d{6,8}$/.test(normalizedCode)) return { valid: false, timestep: null };
1351
+ const now = timestampMs ?? Date.now();
1352
+ const currentStep = Math.floor(now / 1e3 / TOTP_STEP_SECONDS);
1353
+ const drift = Math.max(0, Math.min(window, 4));
1354
+ let matched = null;
1355
+ for (let step = currentStep - drift; step <= currentStep + drift; step++) {
1356
+ const expected = hotp(key, step, normalizedCode.length);
1357
+ const expectedBuffer = Buffer.from(expected);
1358
+ const actualBuffer = Buffer.from(normalizedCode);
1359
+ if (expectedBuffer.length === actualBuffer.length && crypto2.timingSafeEqual(expectedBuffer, actualBuffer)) {
1360
+ matched ??= step;
1361
+ }
1362
+ }
1363
+ return matched === null ? { valid: false, timestep: null } : { valid: true, timestep: matched };
1364
+ };
1365
+ var generateTotpSecret = () => base32Encode(crypto2.randomBytes(20));
1366
+ var buildOtpauthUri = ({ secret, accountName, issuer }) => {
1367
+ const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`;
1368
+ const query = new URLSearchParams({
1369
+ secret,
1370
+ issuer,
1371
+ algorithm: "SHA1",
1372
+ digits: "6",
1373
+ period: String(TOTP_STEP_SECONDS)
1374
+ });
1375
+ return `otpauth://totp/${label}?${query.toString()}`;
1376
+ };
1377
+
1378
+ // src/twoFactor.ts
1379
+ var sha256 = (value) => crypto3.createHash("sha256").update(value).digest("hex");
1380
+ var ENCRYPTED_PREFIX = "gcm:";
1381
+ var warnedPlaintextSecret = false;
1382
+ var encryptionKey = () => {
1383
+ const raw = process.env.TOTP_ENCRYPTION_KEY;
1384
+ if (!raw) return null;
1385
+ return crypto3.createHash("sha256").update(raw).digest();
1386
+ };
1387
+ var encryptTotpSecret = (secret) => {
1388
+ const key = encryptionKey();
1389
+ if (!key) {
1390
+ if (!warnedPlaintextSecret) {
1391
+ warnedPlaintextSecret = true;
1392
+ getLogger5().warn("[2fa] TOTP_ENCRYPTION_KEY is not set \u2014 TOTP secrets are stored unencrypted. Set it in .env.local to encrypt new enrollments at rest.");
1393
+ }
1394
+ return secret;
1395
+ }
1396
+ const iv = crypto3.randomBytes(12);
1397
+ const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
1398
+ const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
1399
+ return `${ENCRYPTED_PREFIX}${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${ciphertext.toString("base64")}`;
1400
+ };
1401
+ var decryptTotpSecret = (stored) => {
1402
+ if (!stored.startsWith(ENCRYPTED_PREFIX)) return stored;
1403
+ const key = encryptionKey();
1404
+ if (!key) {
1405
+ getLogger5().error("[2fa] found an encrypted TOTP secret but TOTP_ENCRYPTION_KEY is not set \u2014 cannot verify TOTP codes.");
1406
+ return null;
1407
+ }
1408
+ const [ivPart, tagPart, dataPart] = stored.slice(ENCRYPTED_PREFIX.length).split(":");
1409
+ if (!ivPart || !tagPart || !dataPart) return null;
1410
+ try {
1411
+ const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
1412
+ decipher.setAuthTag(Buffer.from(tagPart, "base64"));
1413
+ return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
1414
+ } catch {
1415
+ getLogger5().error("[2fa] TOTP secret decryption failed (wrong TOTP_ENCRYPTION_KEY?).");
1416
+ return null;
1417
+ }
1418
+ };
1419
+ var challengeKey = (token) => formatKey5("-2fa-challenge", sha256(token));
1420
+ var challengeAttemptsKey = (token) => formatKey5("-2fa-challenge-attempts", sha256(token));
1421
+ var usedTimestepKey = (userId, timestep) => formatKey5("-2fa-usedstep", `${userId}:${String(timestep)}`);
1422
+ var accountFailureKey = (userId) => formatKey5("-2fa-accountfail", userId);
1423
+ var RECOVERY_LEASE_MS = 5e3;
1424
+ var recoveryLeaseName = (userId) => `2fa-recovery:${userId}`;
1425
+ var pendingSecretKey = (userId) => formatKey5("-2fa-pending", userId);
1426
+ var ACCOUNT_FAIL_LIMIT = 10;
1427
+ var ACCOUNT_FAIL_WINDOW_SECONDS = 15 * 60;
1428
+ var isTwoFactorAccountLocked = async (userId) => {
1429
+ const count = Number(await redis4.get(accountFailureKey(userId)) ?? "0");
1430
+ return count >= ACCOUNT_FAIL_LIMIT;
1431
+ };
1432
+ var recordTwoFactorAccountFailure = async (userId) => {
1433
+ const key = accountFailureKey(userId);
1434
+ const count = await redis4.incr(key);
1435
+ if (count === 1) await redis4.expire(key, ACCOUNT_FAIL_WINDOW_SECONDS);
1436
+ };
1437
+ var clearTwoFactorAccountFailures = async (userId) => {
1438
+ await redis4.del(accountFailureKey(userId));
1439
+ };
1440
+ var twoFactorConfig = () => getProjectConfig9().auth;
1441
+ var availableTwoFactorMethods = (user) => {
1442
+ const methods = [];
1443
+ if (user.totpSecret) methods.push("totp");
1444
+ if (twoFactorConfig().twoFactorEmailFallback && user.email) methods.push("email-code");
1445
+ if ((user.recoveryCodes ?? []).length > 0) methods.push("recovery-code");
1446
+ return methods;
1447
+ };
1448
+ var createTwoFactorChallengeIfRequired = async (user, _context) => {
1449
+ if (twoFactorConfig().twoFactor === "disabled") return null;
1450
+ if (!user.twoFactorEnabled || !user.totpSecret) return null;
1451
+ const methods = availableTwoFactorMethods(user);
1452
+ const token = crypto3.randomBytes(32).toString("hex");
1453
+ const record = { userId: user.id, email: user.email ?? void 0 };
1454
+ await redis4.set(challengeKey(token), JSON.stringify(record), "EX", twoFactorConfig().twoFactorChallengeTtlSeconds);
1455
+ return {
1456
+ status: true,
1457
+ reason: "login.twoFactorRequired",
1458
+ requiresTwoFactor: true,
1459
+ challengeToken: token,
1460
+ twoFactorMethods: methods
1461
+ };
1462
+ };
1463
+ var readChallenge = async (token) => {
1464
+ if (!/^[a-f0-9]{64}$/.test(token)) return null;
1465
+ const raw = await redis4.get(challengeKey(token));
1466
+ if (!raw) return null;
1467
+ const [parseError, parsed] = await tryCatch3(() => Promise.resolve(JSON.parse(raw)));
1468
+ if (parseError || typeof parsed?.userId !== "string") return null;
1469
+ return parsed;
1470
+ };
1471
+ var burnChallenge = async (token) => {
1472
+ await redis4.del(challengeKey(token));
1473
+ await redis4.del(challengeAttemptsKey(token));
1474
+ };
1475
+ var verifyTotpForUser = async (user, code, enforceSingleUse) => {
1476
+ if (!user.totpSecret) return false;
1477
+ if (!/^\d{6}$/.test(code.trim())) return false;
1478
+ const secret = decryptTotpSecret(user.totpSecret);
1479
+ if (!secret) return false;
1480
+ const result = verifyTotp({ secret, code });
1481
+ if (!result.valid || result.timestep === null) return false;
1482
+ if (!enforceSingleUse) return true;
1483
+ const claimed = await redis4.set(usedTimestepKey(user.id, result.timestep), "1", "EX", 60 * 2, "NX");
1484
+ return claimed !== null;
1485
+ };
1486
+ var matchRecoveryHash = (hashes, code) => {
1487
+ const submitted = Buffer.from(sha256(code.trim().toLowerCase()));
1488
+ return hashes.findIndex((hash2) => {
1489
+ const stored = Buffer.from(hash2);
1490
+ return stored.length === submitted.length && crypto3.timingSafeEqual(stored, submitted);
1491
+ });
1492
+ };
1493
+ var consumeRecoveryCode = async (user, code) => {
1494
+ if ((user.recoveryCodes ?? []).length === 0) return false;
1495
+ const leaseToken = await acquireLease(recoveryLeaseName(user.id), RECOVERY_LEASE_MS);
1496
+ if (!leaseToken) {
1497
+ getLogger5().warn("[2fa] recovery-code burn is contended (lease busy) \u2014 refusing this attempt, retry");
1498
+ return false;
1499
+ }
1500
+ try {
1501
+ const fresh = await getUserAdapter().findById(user.id);
1502
+ const hashes = fresh?.recoveryCodes ?? [];
1503
+ const index = matchRecoveryHash(hashes, code);
1504
+ if (index === -1) return false;
1505
+ const remaining = hashes.filter((_, position) => position !== index);
1506
+ const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, { recoveryCodes: remaining }));
1507
+ if (updateError) {
1508
+ getLogger5().error("[2fa] could not persist recovery-code consumption \u2014 refusing the code", updateError);
1509
+ return false;
1510
+ }
1511
+ return true;
1512
+ } finally {
1513
+ await releaseLease(recoveryLeaseName(user.id), leaseToken);
1514
+ }
1515
+ };
1516
+ var verifyTwoFactorChallenge = async (input) => {
1517
+ const challenge = await readChallenge(input.challengeToken);
1518
+ if (!challenge) return { status: false, reason: "login.twoFactorChallengeExpired" };
1519
+ if (await isTwoFactorAccountLocked(challenge.userId)) {
1520
+ return { status: false, reason: "login.twoFactorLocked" };
1521
+ }
1522
+ const counter = challengeAttemptsKey(input.challengeToken);
1523
+ const attempts = await redis4.incr(counter);
1524
+ const remaining = await redis4.ttl(challengeKey(input.challengeToken));
1525
+ if (remaining > 0) await redis4.expire(counter, remaining);
1526
+ if (attempts > twoFactorConfig().twoFactorMaxAttempts) {
1527
+ await burnChallenge(input.challengeToken);
1528
+ return { status: false, reason: "login.twoFactorLocked" };
1529
+ }
1530
+ const user = await getUserAdapter().findById(challenge.userId);
1531
+ if (!user) {
1532
+ await burnChallenge(input.challengeToken);
1533
+ return { status: false, reason: "login.twoFactorChallengeExpired" };
1534
+ }
1535
+ const method = input.method ?? "totp";
1536
+ let verified = false;
1537
+ if (method === "totp") {
1538
+ verified = await verifyTotpForUser(user, input.code, true);
1539
+ } else if (method === "email-code") {
1540
+ if (!twoFactorConfig().twoFactorEmailFallback) return { status: false, reason: "login.twoFactorMethodUnavailable" };
1541
+ verified = await verifyEmailCode({
1542
+ purpose: "2fa",
1543
+ identity: user.id,
1544
+ code: input.code,
1545
+ maxAttempts: twoFactorConfig().emailCodeMaxAttempts
1546
+ }) === "valid";
1547
+ } else {
1548
+ verified = await consumeRecoveryCode(user, input.code);
1549
+ }
1550
+ if (!verified) {
1551
+ await recordTwoFactorAccountFailure(user.id);
1552
+ void dispatchHook3("loginFailed", { email: user.email ?? void 0, userId: user.id, provider: "credentials", reason: "login.twoFactorInvalidCode", stage: "login", requesterIp: input.requesterIp });
1553
+ return { status: false, reason: "login.twoFactorInvalidCode" };
1554
+ }
1555
+ await burnChallenge(input.challengeToken);
1556
+ await clearEmailCode("2fa", user.id);
1557
+ await clearTwoFactorAccountFailures(user.id);
1558
+ return finalizeLogin(user, {
1559
+ provider: "credentials",
1560
+ email: user.email ?? void 0,
1561
+ supersedeToken: input.supersedeToken,
1562
+ requesterIp: input.requesterIp
1563
+ });
1564
+ };
1565
+ var requestTwoFactorEmailCode = async (challengeToken) => {
1566
+ if (!twoFactorConfig().twoFactorEmailFallback) return { ok: false, reason: "login.twoFactorMethodUnavailable" };
1567
+ const challenge = await readChallenge(challengeToken);
1568
+ if (!challenge) return { ok: false, reason: "login.twoFactorChallengeExpired" };
1569
+ const user = await getUserAdapter().findById(challenge.userId);
1570
+ if (!user?.email) return { ok: false, reason: "login.twoFactorChallengeExpired" };
1571
+ const config = twoFactorConfig();
1572
+ const code = await issueEmailCode({
1573
+ purpose: "2fa",
1574
+ identity: user.id,
1575
+ ttlSeconds: config.emailCodeTtlSeconds,
1576
+ digits: config.emailCodeLength
1577
+ });
1578
+ const sent = await sendCodeEmail(user.email, code, "Your verification code", `Use this code to finish signing in to ${getProjectName()}.`);
1579
+ return sent ? { ok: true } : { ok: false, reason: "login.emailCodeSendFailed" };
1580
+ };
1581
+ var sendCodeEmail = async (to, code, subject, intro) => {
1582
+ const [loadError, email] = await tryCatch3(() => loadEmailModule());
1583
+ if (loadError || !email) {
1584
+ getLogger5().error("[2fa/email-code] @luckystack/email is not installed \u2014 cannot send the code email.");
1585
+ return false;
1586
+ }
1587
+ const spaced = code.match(/\d/g)?.join(" ") ?? code;
1588
+ const [sendError, result] = await tryCatch3(() => email.sendEmail({
1589
+ to,
1590
+ subject,
1591
+ html: `<p>${intro}</p><p style="font-size:28px;font-weight:700;letter-spacing:6px;font-family:monospace">${spaced}</p><p>The code expires in ${String(Math.round(twoFactorConfig().emailCodeTtlSeconds / 60))} minutes. If you didn't request it, you can ignore this email.</p>`,
1592
+ text: `${intro}
1593
+
1594
+ Code: ${code}
1595
+
1596
+ The code expires in ${String(Math.round(twoFactorConfig().emailCodeTtlSeconds / 60))} minutes. If you didn't request it, you can ignore this email.`,
1597
+ adapterHint: "transactional"
1598
+ }));
1599
+ if (sendError || !result?.ok) {
1600
+ getLogger5().error("[2fa/email-code] sending the code email failed", sendError ?? new Error(result?.reason ?? "unknown"));
1601
+ return false;
1602
+ }
1603
+ return true;
1604
+ };
1605
+ var enrollmentBlockedReason = (user) => {
1606
+ if (twoFactorConfig().twoFactor === "disabled") return "login.twoFactorDisabledByServer";
1607
+ if (user.twoFactorEnabled) return "login.twoFactorAlreadyEnabled";
1608
+ return null;
1609
+ };
1610
+ var beginTotpEnrollment = async (user) => {
1611
+ const blocked = enrollmentBlockedReason(user);
1612
+ if (blocked) return { ok: false, reason: blocked };
1613
+ const secret = generateTotpSecret();
1614
+ await redis4.set(pendingSecretKey(user.id), encryptTotpSecret(secret), "EX", 60 * 10);
1615
+ return {
1616
+ ok: true,
1617
+ secret,
1618
+ otpauthUri: buildOtpauthUri({ secret, accountName: user.email ?? user.id, issuer: getProjectName() })
1619
+ };
1620
+ };
1621
+ var generateRecoveryCodes = () => {
1622
+ const raw = Array.from({ length: 10 }, () => {
1623
+ const hex = crypto3.randomBytes(10).toString("hex");
1624
+ return `${hex.slice(0, 5)}-${hex.slice(5, 10)}-${hex.slice(10, 15)}-${hex.slice(15, 20)}`;
1625
+ });
1626
+ return { raw, hashes: raw.map((code) => sha256(code.toLowerCase())) };
1627
+ };
1628
+ var confirmTotpEnrollment = async (user, code) => {
1629
+ const blocked = enrollmentBlockedReason(user);
1630
+ if (blocked) return { ok: false, reason: blocked };
1631
+ const pendingStored = await redis4.get(pendingSecretKey(user.id));
1632
+ if (!pendingStored) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
1633
+ const secret = decryptTotpSecret(pendingStored);
1634
+ if (!secret) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
1635
+ const result = verifyTotp({ secret, code });
1636
+ if (!result.valid) return { ok: false, reason: "login.twoFactorInvalidCode" };
1637
+ const { raw, hashes } = generateRecoveryCodes();
1638
+ const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
1639
+ totpSecret: encryptTotpSecret(secret),
1640
+ twoFactorEnabled: true,
1641
+ recoveryCodes: hashes
1642
+ }));
1643
+ if (updateError) {
1644
+ getLogger5().error("[2fa] enrollment persist failed \u2014 is your UserAdapter/schema missing the 2FA fields? (twoFactorEnabled, totpSecret, recoveryCodes)", updateError);
1645
+ return { ok: false, reason: "login.twoFactorPersistFailed" };
1646
+ }
1647
+ await redis4.del(pendingSecretKey(user.id));
1648
+ return { ok: true, recoveryCodes: raw };
1649
+ };
1650
+ var disableTwoFactor = async (user, code) => {
1651
+ const viaTotp = await verifyTotpForUser(user, code, false);
1652
+ const viaRecovery = viaTotp ? false : await consumeRecoveryCode(user, code);
1653
+ if (!viaTotp && !viaRecovery) return { ok: false, reason: "login.twoFactorInvalidCode" };
1654
+ const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
1655
+ totpSecret: null,
1656
+ twoFactorEnabled: false,
1657
+ recoveryCodes: []
1658
+ }));
1659
+ if (updateError) {
1660
+ getLogger5().error("[2fa] disable persist failed", updateError);
1661
+ return { ok: false, reason: "login.twoFactorPersistFailed" };
1662
+ }
1663
+ await clearTwoFactorAccountFailures(user.id);
1664
+ return { ok: true };
1665
+ };
1666
+ var regenerateRecoveryCodes = async (user, code) => {
1667
+ if (!await verifyTotpForUser(user, code, false)) return { ok: false, reason: "login.twoFactorInvalidCode" };
1668
+ const { raw, hashes } = generateRecoveryCodes();
1669
+ const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, { recoveryCodes: hashes }));
1670
+ if (updateError) {
1671
+ getLogger5().error("[2fa] recovery-code regenerate persist failed", updateError);
1672
+ return { ok: false, reason: "login.twoFactorPersistFailed" };
1673
+ }
1674
+ return { ok: true, recoveryCodes: raw };
1675
+ };
1676
+ registerTwoFactorGate(createTwoFactorChallengeIfRequired);
1677
+
1678
+ // src/emailCodeLogin.ts
1679
+ import { checkRateLimit as checkRateLimit2, getLogger as getLogger6, getProjectConfig as getProjectConfig10, getProjectName as getProjectName2, tryCatch as tryCatch4 } from "@luckystack/core";
1680
+ var emailCodeConfig = () => getProjectConfig10().auth;
1681
+ var normalizeEmail = (email) => email.trim().toLowerCase();
1682
+ var REQUEST_WINDOW_MS = 15 * 60 * 1e3;
1683
+ var REQUESTS_PER_EMAIL = 3;
1684
+ var REQUESTS_PER_IP = 10;
1685
+ var requestEmailLoginCode = async ({ email, requesterIp }) => {
1686
+ const config = emailCodeConfig();
1687
+ if (!config.emailCodeLogin) return { ok: false, reason: "login.emailCodeDisabled" };
1688
+ const normalized = normalizeEmail(email);
1689
+ if (!normalized || normalized.length > config.emailMaxLength) return { ok: false, reason: "login.invalidEmailFormat" };
1690
+ const perEmail = await checkRateLimit2({ key: `email-code:req:${normalized}`, limit: REQUESTS_PER_EMAIL, windowMs: REQUEST_WINDOW_MS });
1691
+ const perIp = requesterIp ? await checkRateLimit2({ key: `email-code:req-ip:${requesterIp}`, limit: REQUESTS_PER_IP, windowMs: REQUEST_WINDOW_MS }) : { allowed: true };
1692
+ if (!perEmail.allowed || !perIp.allowed) return { ok: false, reason: "api.rateLimitExceeded" };
1693
+ const [findError, user] = await tryCatch4(
1694
+ () => resolveUserByEmail(getUserAdapter(), { email: normalized, provider: "credentials" })
1695
+ );
1696
+ if (findError) {
1697
+ getLogger6().error("email-code login: user lookup failed", findError);
1698
+ return { ok: true };
1699
+ }
1700
+ if (!user) {
1701
+ getLogger6().info("[email-code] request for unknown address (not sent)");
1702
+ return { ok: true };
1703
+ }
1704
+ const code = await issueEmailCode({
1705
+ purpose: "login",
1706
+ identity: normalized,
1707
+ ttlSeconds: config.emailCodeTtlSeconds,
1708
+ digits: config.emailCodeLength
1709
+ });
1710
+ void sendCodeEmail(
1711
+ normalized,
1712
+ code,
1713
+ "Your sign-in code",
1714
+ `Use this code to sign in to ${getProjectName2()}.`
1715
+ ).then((sent) => {
1716
+ if (!sent) getLogger6().error("[email-code] login code send failed (account exists) \u2014 check the email adapter");
1717
+ });
1718
+ return { ok: true };
1719
+ };
1720
+ var verifyEmailLoginCode = async (input) => {
1721
+ const config = emailCodeConfig();
1722
+ if (!config.emailCodeLogin) return { status: false, reason: "login.emailCodeDisabled" };
1723
+ const normalized = normalizeEmail(input.email);
1724
+ const verdict = await verifyEmailCode({
1725
+ purpose: "login",
1726
+ identity: normalized,
1727
+ code: input.code,
1728
+ maxAttempts: config.emailCodeMaxAttempts
1729
+ });
1730
+ if (verdict !== "valid") {
1731
+ const reason = verdict === "invalid" ? "login.emailCodeInvalid" : verdict === "locked" ? "login.emailCodeLocked" : "login.emailCodeExpired";
1732
+ return { status: false, reason };
1733
+ }
1734
+ const [findError, user] = await tryCatch4(
1735
+ () => resolveUserByEmail(getUserAdapter(), { email: normalized, provider: "credentials" })
1736
+ );
1737
+ if (findError || !user) {
1738
+ getLogger6().error("email-code login: user vanished between issue and verify", findError ?? void 0);
1739
+ return { status: false, reason: "login.emailCodeExpired" };
1740
+ }
1741
+ await clearEmailCode("login", normalized);
1742
+ const challenge = await createTwoFactorChallengeIfRequired(user, { requesterIp: input.requesterIp });
1743
+ if (challenge) return challenge;
1744
+ return finalizeLogin(user, {
1745
+ provider: "credentials",
1746
+ email: normalized,
1747
+ supersedeToken: input.supersedeToken,
1748
+ requesterIp: input.requesterIp
1749
+ });
1750
+ };
1751
+
1241
1752
  // src/index.ts
1242
1753
  registerSessionProvider({
1243
1754
  getSession,
@@ -1251,17 +1762,29 @@ export {
1251
1762
  PasswordPolicyError,
1252
1763
  activeUsersKeyFor,
1253
1764
  asOAuthUserData,
1765
+ availableTwoFactorMethods,
1766
+ base32Decode,
1767
+ base32Encode,
1768
+ beginTotpEnrollment,
1769
+ buildOtpauthUri,
1254
1770
  clearAuthFailures,
1771
+ clearEmailCode,
1772
+ confirmTotpEnrollment,
1255
1773
  consumeEmailChangeToken,
1256
1774
  consumePasswordResetToken,
1257
1775
  createEmailChangeToken,
1258
1776
  createOAuthState,
1259
1777
  createPasswordResetToken,
1778
+ createTwoFactorChallengeIfRequired,
1260
1779
  credentialsProvider,
1261
1780
  defaultPrismaUserAdapter,
1262
1781
  deleteSession,
1782
+ disableTwoFactor,
1263
1783
  discordProvider,
1264
1784
  facebookProvider,
1785
+ finalizeLogin,
1786
+ generateNumericCode,
1787
+ generateTotpSecret,
1265
1788
  getAllSessions,
1266
1789
  getOAuthProviders,
1267
1790
  getPostLoginRedirect,
@@ -1271,9 +1794,11 @@ export {
1271
1794
  getUserAdapter,
1272
1795
  githubProvider,
1273
1796
  googleProvider,
1797
+ hotp,
1274
1798
  isAccountLocked,
1275
1799
  isFullOAuthProvider,
1276
1800
  isUserAdapterRegistered,
1801
+ issueEmailCode,
1277
1802
  loginCallback,
1278
1803
  loginWithCredentials,
1279
1804
  loginWithCredentialsCore,
@@ -1281,13 +1806,17 @@ export {
1281
1806
  microsoftProvider,
1282
1807
  recordAuthFailure,
1283
1808
  redisSessionAdapter,
1809
+ regenerateRecoveryCodes,
1284
1810
  registerOAuthProviderFactory,
1285
1811
  registerOAuthProviders,
1286
1812
  registerPostLoginRedirect,
1287
1813
  registerSessionAdapter,
1288
1814
  registerSessionSanitizer,
1815
+ registerTwoFactorGate,
1289
1816
  registerUserAdapter,
1290
1817
  registerWithCredentials,
1818
+ requestEmailLoginCode,
1819
+ requestTwoFactorEmailCode,
1291
1820
  revokeUserSessions,
1292
1821
  saveSession,
1293
1822
  sendEmailChangeConfirmation,
@@ -1295,6 +1824,10 @@ export {
1295
1824
  sessionKeyFor,
1296
1825
  updatePasswordHash,
1297
1826
  validatePassword,
1298
- verifyPassword
1827
+ verifyEmailCode,
1828
+ verifyEmailLoginCode,
1829
+ verifyPassword,
1830
+ verifyTotp,
1831
+ verifyTwoFactorChallenge
1299
1832
  };
1300
1833
  //# sourceMappingURL=index.js.map