@luckystack/login 0.7.2 → 0.7.4

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-L5MHCPNN.js";
17
+ } from "./chunk-CLYMWF3A.js";
18
18
  import {
19
19
  activeUsersKeyFor,
20
20
  deleteSession,
@@ -139,10 +139,20 @@ var registerPostLoginRedirect = (resolver) => {
139
139
  return resolver;
140
140
  };
141
141
  var getPostLoginRedirect = () => activeResolver;
142
+ var RELATIVE_BASE = "http://luckystack-relative.invalid";
143
+ var resolvePostLoginRedirectAgainstDefault = (candidate, defaultUrl) => {
144
+ if (!URL.canParse(candidate, RELATIVE_BASE)) return candidate;
145
+ const parsed = new URL(candidate, RELATIVE_BASE);
146
+ if (parsed.origin !== RELATIVE_BASE) return candidate;
147
+ if (!URL.canParse(defaultUrl)) return candidate;
148
+ const fallback = new URL(defaultUrl);
149
+ if (fallback.protocol !== "http:" && fallback.protocol !== "https:") return candidate;
150
+ return new URL(candidate, fallback).toString();
151
+ };
142
152
 
143
153
  // src/login.ts
144
154
  import { URLSearchParams as URLSearchParams2 } from "url";
145
- import { tryCatch as tryCatch2, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue } from "@luckystack/core";
155
+ import { tryCatch as tryCatch2, tryCatchSync, redis as redisClient, getUploadsDir, dispatchHook, getLogger as getLogger3, formatKey, getProjectConfig as getProjectConfig4, getCookieValue, resolveDevCallbackUrl } from "@luckystack/core";
146
156
  import bcrypt from "bcryptjs";
147
157
  import { randomBytes, createHash, timingSafeEqual } from "crypto";
148
158
  import validator from "validator";
@@ -773,11 +783,12 @@ var isAllowedRedirectUrl = (url) => {
773
783
  });
774
784
  };
775
785
  var exchangeOAuthToken = async (provider, code, codeVerifier) => {
786
+ const redirectUri = resolveDevCallbackUrl(provider.callbackURL);
776
787
  const values = {
777
788
  code,
778
789
  client_id: provider.clientID,
779
790
  client_secret: provider.clientSecret,
780
- redirect_uri: provider.callbackURL,
791
+ redirect_uri: redirectUri,
781
792
  grant_type: "authorization_code",
782
793
  ...codeVerifier ? { code_verifier: codeVerifier } : {}
783
794
  };
@@ -799,7 +810,7 @@ var exchangeOAuthToken = async (provider, code, codeVerifier) => {
799
810
  formParams.append("client_secret", provider.clientSecret);
800
811
  formParams.append("code", values.code);
801
812
  formParams.append("grant_type", "authorization_code");
802
- formParams.append("redirect_uri", provider.callbackURL);
813
+ formParams.append("redirect_uri", redirectUri);
803
814
  if (codeVerifier) {
804
815
  formParams.append("code_verifier", codeVerifier);
805
816
  }
@@ -986,7 +997,9 @@ var resolvePostLoginRedirect = async ({
986
997
  getLogger3().warn(`[oauth] postLoginRedirect resolver threw`, { message: resolverError.message });
987
998
  return fallbackUrl;
988
999
  }
989
- if (resolved && isAllowedRedirectUrl(resolved)) return resolved;
1000
+ if (resolved && isAllowedRedirectUrl(resolved)) {
1001
+ return resolvePostLoginRedirectAgainstDefault(resolved, fallbackUrl);
1002
+ }
990
1003
  if (resolved) {
991
1004
  getLogger3().warn(
992
1005
  `[oauth] postLoginRedirect returned a URL not in allowed origins \u2014 falling back`,
@@ -1266,33 +1279,53 @@ var normalizeIdentity = (identity) => identity.trim().toLowerCase();
1266
1279
  var codeKey = (purpose, identity) => formatKey4("-emailcode", `${purpose}:${normalizeIdentity(identity)}`);
1267
1280
  var attemptsKey = (purpose, identity) => formatKey4("-emailcode-attempts", `${purpose}:${normalizeIdentity(identity)}`);
1268
1281
  var hashCode = (code) => crypto.createHash("sha256").update(code).digest("hex");
1282
+ var ISSUE_CODE_SCRIPT = `
1283
+ redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
1284
+ redis.call('del', KEYS[2])
1285
+ return 1
1286
+ `;
1287
+ var VERIFY_CODE_SCRIPT = `
1288
+ local stored = redis.call('get', KEYS[1])
1289
+ if not stored then return 0 end
1290
+ local attempts = redis.call('incr', KEYS[2])
1291
+ local remaining = redis.call('ttl', KEYS[1])
1292
+ redis.call('expire', KEYS[2], remaining > 0 and remaining or 600)
1293
+ if attempts > tonumber(ARGV[2]) then
1294
+ redis.call('del', KEYS[1])
1295
+ redis.call('del', KEYS[2])
1296
+ return 3
1297
+ end
1298
+ if stored ~= ARGV[1] then return 1 end
1299
+ redis.call('del', KEYS[1])
1300
+ redis.call('del', KEYS[2])
1301
+ return 2
1302
+ `;
1269
1303
  var generateNumericCode = (digits) => String(crypto.randomInt(0, 10 ** digits)).padStart(digits, "0");
1270
1304
  var issueEmailCode = async ({ purpose, identity, ttlSeconds, digits }) => {
1271
1305
  const code = generateNumericCode(digits);
1272
- await redis3.set(codeKey(purpose, identity), hashCode(code), "EX", ttlSeconds);
1273
- await redis3.del(attemptsKey(purpose, identity));
1306
+ await redis3.eval(
1307
+ ISSUE_CODE_SCRIPT,
1308
+ 2,
1309
+ codeKey(purpose, identity),
1310
+ attemptsKey(purpose, identity),
1311
+ hashCode(code),
1312
+ String(ttlSeconds)
1313
+ );
1274
1314
  return code;
1275
1315
  };
1276
1316
  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";
1317
+ const result = Number(await redis3.eval(
1318
+ VERIFY_CODE_SCRIPT,
1319
+ 2,
1320
+ codeKey(purpose, identity),
1321
+ attemptsKey(purpose, identity),
1322
+ hashCode(code.trim()),
1323
+ String(maxAttempts)
1324
+ ));
1325
+ if (result === 2) return "valid";
1326
+ if (result === 1) return "invalid";
1327
+ if (result === 3) return "locked";
1328
+ return "expired";
1296
1329
  };
1297
1330
  var clearEmailCode = async (purpose, identity) => {
1298
1331
  await redis3.del(codeKey(purpose, identity));
@@ -1377,16 +1410,65 @@ var buildOtpauthUri = ({ secret, accountName, issuer }) => {
1377
1410
 
1378
1411
  // src/twoFactor.ts
1379
1412
  var sha256 = (value) => crypto3.createHash("sha256").update(value).digest("hex");
1380
- var ENCRYPTED_PREFIX = "gcm:";
1413
+ var LEGACY_ENCRYPTED_PREFIX = "gcm:";
1414
+ var VERSIONED_ENCRYPTED_PREFIX = "enc:v2:";
1415
+ var KEY_ID_LENGTH = 24;
1381
1416
  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();
1417
+ var warnedInvalidLegacyKeyRing = false;
1418
+ var toEncryptionKey = (raw) => ({
1419
+ id: crypto3.createHash("sha256").update(`luckystack-totp-key-id:${raw}`).digest("hex").slice(0, KEY_ID_LENGTH),
1420
+ key: crypto3.createHash("sha256").update(raw).digest()
1421
+ });
1422
+ var readLegacyEncryptionKeys = () => {
1423
+ const raw = process.env.TOTP_ENCRYPTION_LEGACY_KEYS;
1424
+ if (!raw) return [];
1425
+ try {
1426
+ const parsed = JSON.parse(raw);
1427
+ if (!Array.isArray(parsed)) {
1428
+ throw new TypeError("expected a JSON array of non-empty strings");
1429
+ }
1430
+ const keys = [];
1431
+ for (const value of parsed) {
1432
+ if (typeof value !== "string" || value.length === 0) {
1433
+ throw new TypeError("expected a JSON array of non-empty strings");
1434
+ }
1435
+ keys.push(value);
1436
+ }
1437
+ return keys;
1438
+ } catch {
1439
+ if (!warnedInvalidLegacyKeyRing) {
1440
+ warnedInvalidLegacyKeyRing = true;
1441
+ getLogger5().error("[2fa] TOTP_ENCRYPTION_LEGACY_KEYS must be a JSON array of non-empty strings; legacy TOTP rows cannot be decrypted until it is fixed.");
1442
+ }
1443
+ return [];
1444
+ }
1445
+ };
1446
+ var encryptionKeyRing = () => {
1447
+ const primaryRaw = process.env.TOTP_ENCRYPTION_KEY;
1448
+ const primary = primaryRaw ? toEncryptionKey(primaryRaw) : null;
1449
+ const seen = /* @__PURE__ */ new Set();
1450
+ const all = [];
1451
+ for (const raw of [primaryRaw, ...readLegacyEncryptionKeys()]) {
1452
+ if (!raw) continue;
1453
+ const entry = toEncryptionKey(raw);
1454
+ if (seen.has(entry.id)) continue;
1455
+ seen.add(entry.id);
1456
+ all.push(entry);
1457
+ }
1458
+ return { primary, all };
1459
+ };
1460
+ var decryptWithKey = (key, ivPart, tagPart, dataPart) => {
1461
+ try {
1462
+ const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(ivPart, "base64"));
1463
+ decipher.setAuthTag(Buffer.from(tagPart, "base64"));
1464
+ return Buffer.concat([decipher.update(Buffer.from(dataPart, "base64")), decipher.final()]).toString("utf8");
1465
+ } catch {
1466
+ return null;
1467
+ }
1386
1468
  };
1387
1469
  var encryptTotpSecret = (secret) => {
1388
- const key = encryptionKey();
1389
- if (!key) {
1470
+ const { primary } = encryptionKeyRing();
1471
+ if (!primary) {
1390
1472
  if (!warnedPlaintextSecret) {
1391
1473
  warnedPlaintextSecret = true;
1392
1474
  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.");
@@ -1394,34 +1476,58 @@ var encryptTotpSecret = (secret) => {
1394
1476
  return secret;
1395
1477
  }
1396
1478
  const iv = crypto3.randomBytes(12);
1397
- const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
1479
+ const cipher = crypto3.createCipheriv("aes-256-gcm", primary.key, iv);
1398
1480
  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.");
1481
+ return `${VERSIONED_ENCRYPTED_PREFIX}${primary.id}:${iv.toString("base64")}:${cipher.getAuthTag().toString("base64")}:${ciphertext.toString("base64")}`;
1482
+ };
1483
+ var decryptTotpSecretWithMetadata = (stored) => {
1484
+ const ring = encryptionKeyRing();
1485
+ if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX) && !stored.startsWith("enc:")) {
1486
+ return { secret: stored, needsReencrypt: ring.primary !== null };
1487
+ }
1488
+ if (stored.startsWith(VERSIONED_ENCRYPTED_PREFIX)) {
1489
+ const [keyId, ivPart2, tagPart2, dataPart2] = stored.slice(VERSIONED_ENCRYPTED_PREFIX.length).split(":");
1490
+ if (!keyId || !ivPart2 || !tagPart2 || !dataPart2) return null;
1491
+ const entry = ring.all.find((candidate) => candidate.id === keyId);
1492
+ if (!entry) {
1493
+ getLogger5().error("[2fa] TOTP secret references an unavailable encryption key id. Add the previous key to TOTP_ENCRYPTION_LEGACY_KEYS.");
1494
+ return null;
1495
+ }
1496
+ const secret = decryptWithKey(entry.key, ivPart2, tagPart2, dataPart2);
1497
+ if (!secret) {
1498
+ getLogger5().error("[2fa] TOTP secret decryption failed (ciphertext is invalid or the configured key is wrong).");
1499
+ return null;
1500
+ }
1501
+ return {
1502
+ secret,
1503
+ needsReencrypt: ring.primary !== null && entry.id !== ring.primary.id
1504
+ };
1505
+ }
1506
+ if (!stored.startsWith(LEGACY_ENCRYPTED_PREFIX)) {
1507
+ getLogger5().error("[2fa] unsupported TOTP ciphertext version.");
1406
1508
  return null;
1407
1509
  }
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?).");
1510
+ const [ivPart, tagPart, dataPart] = stored.slice(LEGACY_ENCRYPTED_PREFIX.length).split(":");
1511
+ if (!ivPart || !tagPart || !dataPart || ring.all.length === 0) {
1512
+ getLogger5().error("[2fa] found a legacy encrypted TOTP secret but no matching primary/legacy key is configured.");
1416
1513
  return null;
1417
1514
  }
1515
+ for (const entry of ring.all) {
1516
+ const secret = decryptWithKey(entry.key, ivPart, tagPart, dataPart);
1517
+ if (secret) return { secret, needsReencrypt: ring.primary !== null };
1518
+ }
1519
+ getLogger5().error("[2fa] legacy TOTP secret decryption failed with every configured key.");
1520
+ return null;
1418
1521
  };
1522
+ var decryptTotpSecret = (stored) => decryptTotpSecretWithMetadata(stored)?.secret ?? null;
1419
1523
  var challengeKey = (token) => formatKey5("-2fa-challenge", sha256(token));
1420
1524
  var challengeAttemptsKey = (token) => formatKey5("-2fa-challenge-attempts", sha256(token));
1421
1525
  var usedTimestepKey = (userId, timestep) => formatKey5("-2fa-usedstep", `${userId}:${String(timestep)}`);
1422
1526
  var accountFailureKey = (userId) => formatKey5("-2fa-accountfail", userId);
1423
1527
  var RECOVERY_LEASE_MS = 5e3;
1424
1528
  var recoveryLeaseName = (userId) => `2fa-recovery:${userId}`;
1529
+ var ENROLLMENT_LEASE_MS = 3e4;
1530
+ var enrollmentLeaseName = (userId) => `2fa-enrollment:${userId}`;
1425
1531
  var pendingSecretKey = (userId) => formatKey5("-2fa-pending", userId);
1426
1532
  var ACCOUNT_FAIL_LIMIT = 10;
1427
1533
  var ACCOUNT_FAIL_WINDOW_SECONDS = 15 * 60;
@@ -1475,13 +1581,23 @@ var burnChallenge = async (token) => {
1475
1581
  var verifyTotpForUser = async (user, code, enforceSingleUse) => {
1476
1582
  if (!user.totpSecret) return false;
1477
1583
  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 });
1584
+ const decrypted = decryptTotpSecretWithMetadata(user.totpSecret);
1585
+ if (!decrypted) return false;
1586
+ const result = verifyTotp({ secret: decrypted.secret, code });
1481
1587
  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;
1588
+ if (enforceSingleUse) {
1589
+ const claimed = await redis4.set(usedTimestepKey(user.id, result.timestep), "1", "EX", 60 * 2, "NX");
1590
+ if (claimed === null) return false;
1591
+ }
1592
+ if (decrypted.needsReencrypt) {
1593
+ const [migrationError] = await tryCatch3(() => getUserAdapter().update(user.id, {
1594
+ totpSecret: encryptTotpSecret(decrypted.secret)
1595
+ }));
1596
+ if (migrationError) {
1597
+ getLogger5().error("[2fa] TOTP encryption-key lazy migration failed; verification succeeded but the existing ciphertext was kept.", migrationError);
1598
+ }
1599
+ }
1600
+ return true;
1485
1601
  };
1486
1602
  var matchRecoveryHash = (hashes, code) => {
1487
1603
  const submitted = Buffer.from(sha256(code.trim().toLowerCase()));
@@ -1628,24 +1744,30 @@ var generateRecoveryCodes = () => {
1628
1744
  var confirmTotpEnrollment = async (user, code) => {
1629
1745
  const blocked = enrollmentBlockedReason(user);
1630
1746
  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" };
1747
+ const leaseToken = await acquireLease(enrollmentLeaseName(user.id), ENROLLMENT_LEASE_MS);
1748
+ if (!leaseToken) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
1749
+ try {
1750
+ const pendingStored = await redis4.get(pendingSecretKey(user.id));
1751
+ if (!pendingStored) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
1752
+ const secret = decryptTotpSecret(pendingStored);
1753
+ if (!secret) return { ok: false, reason: "login.twoFactorEnrollmentExpired" };
1754
+ const result = verifyTotp({ secret, code });
1755
+ if (!result.valid) return { ok: false, reason: "login.twoFactorInvalidCode" };
1756
+ const { raw, hashes } = generateRecoveryCodes();
1757
+ const [updateError] = await tryCatch3(() => getUserAdapter().update(user.id, {
1758
+ totpSecret: encryptTotpSecret(secret),
1759
+ twoFactorEnabled: true,
1760
+ recoveryCodes: hashes
1761
+ }));
1762
+ if (updateError) {
1763
+ getLogger5().error("[2fa] enrollment persist failed \u2014 is your UserAdapter/schema missing the 2FA fields? (twoFactorEnabled, totpSecret, recoveryCodes)", updateError);
1764
+ return { ok: false, reason: "login.twoFactorPersistFailed" };
1765
+ }
1766
+ await redis4.del(pendingSecretKey(user.id));
1767
+ return { ok: true, recoveryCodes: raw };
1768
+ } finally {
1769
+ await releaseLease(enrollmentLeaseName(user.id), leaseToken);
1646
1770
  }
1647
- await redis4.del(pendingSecretKey(user.id));
1648
- return { ok: true, recoveryCodes: raw };
1649
1771
  };
1650
1772
  var disableTwoFactor = async (user, code) => {
1651
1773
  const viaTotp = await verifyTotpForUser(user, code, false);