@fonderie/auth 7.0.1 → 7.1.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/brain/outcomes.md CHANGED
@@ -61,6 +61,7 @@ user_id UUID PRIMARY KEY REFERENCES fonderie_users(id) ON DELET
61
61
  expires_at TIMESTAMPTZ NOT NULL
62
62
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
63
63
  pin TEXT NOT NULL UNIQUE
64
+ token TEXT
64
65
  -- INDEX idx_fonderie_password_resets_pin (pin)
65
66
  ```
66
67
 
@@ -58,6 +58,7 @@ interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
58
58
  accessTokenDuration?: string;
59
59
  providers: ('email' | 'phone' | 'google' | 'github')[];
60
60
  appName?: string;
61
+ passwordResetUrl?: string;
61
62
  resolve?: (ctx: {
62
63
  meta: Record<string, unknown>;
63
64
  }) => Partial<IAuthRuntimeConfig>;
package/dist/index.cjs CHANGED
@@ -201,7 +201,10 @@ var loginSchema = import_zod.z.union([
201
201
  ]);
202
202
  var refreshSchema = import_zod.z.object({ refreshToken: import_zod.z.string().min(1).optional() });
203
203
  var forgotPasswordSchema = import_zod.z.object({ email });
204
- var resetPasswordSchema = import_zod.z.object({ pin: sixDigitPin, password });
204
+ var resetPasswordSchema = import_zod.z.union([
205
+ import_zod.z.object({ pin: sixDigitPin, password }),
206
+ import_zod.z.object({ token: import_zod.z.string().trim().min(32, "invalid reset token"), password })
207
+ ]);
205
208
  var verifySchema = import_zod.z.object({ token: sixDigitPin });
206
209
  var updateProfileSchema = import_zod.z.object({
207
210
  firstName: import_zod.z.string().max(100).nullable().optional(),
@@ -1198,13 +1201,13 @@ var PasswordResetModel = class {
1198
1201
  this.store = store;
1199
1202
  }
1200
1203
  store;
1201
- async create(userId, pin, expiresAt) {
1204
+ async create(userId, pin, token, expiresAt) {
1202
1205
  await this.store.query(
1203
- `INSERT INTO fonderie_password_resets (user_id, pin, expires_at, created_at)
1204
- VALUES ($1, $2, $3, now())
1206
+ `INSERT INTO fonderie_password_resets (user_id, pin, token, expires_at, created_at)
1207
+ VALUES ($1, $2, $3, $4, now())
1205
1208
  ON CONFLICT (user_id) DO UPDATE
1206
- SET pin = $2, expires_at = $3, created_at = now()`,
1207
- [userId, pin, expiresAt]
1209
+ SET pin = $2, token = $3, expires_at = $4, created_at = now()`,
1210
+ [userId, pin, token, expiresAt]
1208
1211
  );
1209
1212
  }
1210
1213
  async findLastSentAt(userId) {
@@ -1223,6 +1226,19 @@ var PasswordResetModel = class {
1223
1226
  if (!row) return null;
1224
1227
  return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
1225
1228
  }
1229
+ // Lookup by the high-entropy reset token. Unlike the 6-digit pin this is
1230
+ // not brute-forceable, so the token path needs no rate limit. Empty/short
1231
+ // values are refused before the query so a NULL/blank token column can
1232
+ // never be matched.
1233
+ async findByToken(token) {
1234
+ if (typeof token !== "string" || token.length < 32) return null;
1235
+ const [row] = await this.store.query(
1236
+ `SELECT user_id, expires_at FROM fonderie_password_resets WHERE token = $1`,
1237
+ [token]
1238
+ );
1239
+ if (!row) return null;
1240
+ return { userId: row.user_id, expiresAt: new Date(row.expires_at) };
1241
+ }
1226
1242
  async deleteByUser(userId) {
1227
1243
  await this.store.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [userId]);
1228
1244
  }
@@ -1692,13 +1708,16 @@ function authController(store, config, bus) {
1692
1708
  );
1693
1709
  }
1694
1710
  const pin = (0, import_node_crypto4.randomInt)(1e5, 1e6).toString();
1711
+ const token = (0, import_node_crypto4.randomBytes)(32).toString("hex");
1695
1712
  const expiresAt = new Date(Date.now() + 1e3 * 60 * 60);
1696
- await passwordReset.create(user.id, pin, expiresAt);
1713
+ await passwordReset.create(user.id, pin, token, expiresAt);
1714
+ const base = resolved.passwordResetUrl;
1715
+ const resetUrl = base ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : "";
1697
1716
  bus?.emit(import_events2.NOTIFICATION_EVENT, {
1698
1717
  type: MESSAGE_KEYS.passwordReset,
1699
1718
  locale: user.locale,
1700
1719
  recipient: { email: email2, phone: null, deviceToken: null },
1701
- data: { pin }
1720
+ data: { pin, token, resetUrl }
1702
1721
  }).catch(() => {
1703
1722
  });
1704
1723
  return (0, import_core5.setApiResponse)(
@@ -1711,19 +1730,9 @@ function authController(store, config, bus) {
1711
1730
  const body = ctx.meta["body"];
1712
1731
  const raw = body?.["pin"];
1713
1732
  const password2 = body?.["password"];
1714
- if (typeof raw !== "string" || typeof password2 !== "string") {
1715
- return (0, import_core5.setApiResponse)(
1716
- import_core5.HTTP.UNPROCESSABLE,
1717
- "INVALID_PARAMETER",
1718
- "pin and password are required"
1719
- );
1720
- }
1721
- if (!/^\d{6}$/.test(raw.trim())) {
1722
- return (0, import_core5.setApiResponse)(
1723
- import_core5.HTTP.UNPROCESSABLE,
1724
- "INVALID_PARAMETER",
1725
- "pin must be a 6-digit code"
1726
- );
1733
+ const rawToken = body?.["token"];
1734
+ if (typeof password2 !== "string") {
1735
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.UNPROCESSABLE, "INVALID_PARAMETER", "password is required");
1727
1736
  }
1728
1737
  if (password2.length < 8) {
1729
1738
  return (0, import_core5.setApiResponse)(
@@ -1732,10 +1741,25 @@ function authController(store, config, bus) {
1732
1741
  "password must be at least 8 characters"
1733
1742
  );
1734
1743
  }
1735
- const pin = raw.trim();
1736
- const row = await passwordReset.findByPin(pin);
1744
+ let row;
1745
+ if (typeof rawToken === "string" && rawToken.trim().length >= 32) {
1746
+ row = await passwordReset.findByToken(rawToken.trim());
1747
+ } else {
1748
+ if (typeof raw !== "string" || !/^\d{6}$/.test(raw.trim())) {
1749
+ return (0, import_core5.setApiResponse)(
1750
+ import_core5.HTTP.UNPROCESSABLE,
1751
+ "INVALID_PARAMETER",
1752
+ "a 6-digit pin or a reset token is required"
1753
+ );
1754
+ }
1755
+ row = await passwordReset.findByPin(raw.trim());
1756
+ }
1737
1757
  if (!row || /* @__PURE__ */ new Date() > row.expiresAt) {
1738
- return (0, import_core5.setApiResponse)(import_core5.HTTP.BAD_REQUEST, "PASSWORD_RESET_FAILED", "Invalid or expired pin");
1758
+ return (0, import_core5.setApiResponse)(
1759
+ import_core5.HTTP.BAD_REQUEST,
1760
+ "PASSWORD_RESET_FAILED",
1761
+ "Invalid or expired reset credentials"
1762
+ );
1739
1763
  }
1740
1764
  const passwordHash = await hashPassword(password2);
1741
1765
  await store.transaction(async (tx) => {
@@ -2750,7 +2774,11 @@ If you made this change, you're all set. If not, contact support right away \u20
2750
2774
  var SAMPLE_PAYLOADS = {
2751
2775
  [MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
2752
2776
  [MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
2753
- [MESSAGE_KEYS.passwordReset]: { pin: "123456" },
2777
+ [MESSAGE_KEYS.passwordReset]: {
2778
+ pin: "123456",
2779
+ token: "0".repeat(64),
2780
+ resetUrl: "https://app.example.com/reset?token=" + "0".repeat(64)
2781
+ },
2754
2782
  [MESSAGE_KEYS.phoneOtp]: { otp: "123456" },
2755
2783
  [MESSAGE_KEYS.mfaEnabled]: {},
2756
2784
  [MESSAGE_KEYS.mfaDisabled]: {},