@fonderie/auth 5.2.0 → 6.0.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
@@ -129,7 +129,7 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
129
129
  | Method | Path | Middleware chain (auth / validation / handler) |
130
130
  |---|---|---|
131
131
  | POST | `/auth/email/forgot` | `ipLimit('forgot') → validate(forgotPasswordSchema) → acctLimit('forgot') → auth.forgotPassword` |
132
- | POST | `/auth/email/reset` | `validate(resetPasswordSchema) → auth.resetPassword` |
132
+ | POST | `/auth/email/reset` | `ipLimit('reset') → validate(resetPasswordSchema) → auth.resetPassword` |
133
133
  | GET | `/auth/google` | `oauth.googleInit` |
134
134
  | GET | `/auth/google/callback` | `oauth.googleCallback` |
135
135
  | POST | `/auth/login` | `ipLimit('login') → validate(loginSchema) → acctLimit('login') → auth.login` |
@@ -141,11 +141,11 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
141
141
  | POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
142
142
  | POST | `/auth/refresh` | `validate(refreshSchema) → auth.refresh` |
143
143
  | POST | `/auth/register` | `ipLimit('register') → validate(registerSchema) → auth.register` |
144
- | GET | `/auth/send-verification` | `requireAuth → auth.sendVerification` |
144
+ | GET | `/auth/send-verification` | `requireAnyAuth → auth.sendVerification` |
145
145
  | GET | `/auth/sessions` | `requireAuth → user.listSessions` |
146
146
  | DELETE | `/auth/sessions/:id` | `requireAuth → user.terminateSession` |
147
147
  | DELETE | `/auth/sessions/others` | `requireAuth → user.terminateOtherSessions` |
148
- | POST | `/auth/verify` | `requireAuth → validate(verifySchema) → auth.verify` |
148
+ | POST | `/auth/verify` | `ipLimit('verify') → requireAnyAuth → validate(verifySchema) → auth.verify` |
149
149
  | DELETE | `/users` | `requireAuth → verifyGate → user.deleteMe` |
150
150
  | GET | `/users` | `requireAuth → user.me` |
151
151
  | PUT | `/users/email` | `requireAuth → verifyGate → validate(updateEmailSchema) → user.updateEmail` |
@@ -203,5 +203,5 @@ interface IAuthRateLimitConfig {
203
203
  rules?: Partial<Record<AuthLimitedRoute, IRateLimitRule | false>>;
204
204
  }
205
205
 
206
- type AuthLimitedRoute = 'login' | 'register' | 'forgot' | 'mfaVerify';
206
+ type AuthLimitedRoute = 'login' | 'register' | 'forgot' | 'reset' | 'verify' | 'mfaVerify';
207
207
  ```
package/dist/index.cjs CHANGED
@@ -126,6 +126,15 @@ var DEFAULTS = {
126
126
  account: { capacity: 3, refillPerSec: 3 / min(60) },
127
127
  accountField: "email"
128
128
  },
129
+ // reset: 10/15min per IP — the reset PIN is a 6-digit code looked up
130
+ // globally, so an unthrottled endpoint is brute-forceable into an account
131
+ // takeover. There's no email in the reset body to key an account bucket on,
132
+ // so this IP bucket is the guard. (Moving reset to a high-entropy opaque
133
+ // token is tracked as a follow-up hardening.)
134
+ reset: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
135
+ // verify: 10/15min per IP — /auth/verify checks a 6-digit email/phone OTP,
136
+ // and in the phone flow that OTP is the login credential itself.
137
+ verify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } },
129
138
  // mfaVerify: 10/15min per IP — TOTP brute-force.
130
139
  mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
131
140
  };
@@ -1458,23 +1467,17 @@ function authController(store, config, bus) {
1458
1467
  reqOpts2
1459
1468
  ).catch(() => {
1460
1469
  });
1461
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1462
- loginMethod: "phone"
1463
- });
1464
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1470
+ const otpToken = issueMfaPendingToken(user.id, config, "phone");
1465
1471
  return Response.json(
1466
1472
  {
1467
1473
  reason: "USER_PHONE_REGISTERED",
1468
1474
  explanation: "Account created. A verification code has been sent to your phone.",
1469
1475
  result: {
1470
- tokens: { access: accessToken, refresh: refreshToken },
1476
+ otpToken,
1471
1477
  user: toUserDTO(user)
1472
1478
  }
1473
1479
  },
1474
- {
1475
- status: 202,
1476
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
1477
- }
1480
+ { status: 202 }
1478
1481
  );
1479
1482
  }
1480
1483
  return (0, import_core5.setApiResponse)(
@@ -1588,23 +1591,14 @@ function authController(store, config, bus) {
1588
1591
  recipient: { email: null, phone: normalizePhone(phone2), deviceToken: null }
1589
1592
  }).catch(() => {
1590
1593
  });
1591
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1592
- loginMethod: "phone"
1593
- });
1594
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1594
+ const otpToken = issueMfaPendingToken(user.id, config, "phone");
1595
1595
  return Response.json(
1596
1596
  {
1597
1597
  reason: "USER_PHONE_OTP_SENT",
1598
1598
  explanation: "A verification code has been sent to your phone.",
1599
- result: {
1600
- tokens: { access: accessToken, refresh: refreshToken },
1601
- user: toUserDTO(user, false)
1602
- }
1599
+ result: { otpToken }
1603
1600
  },
1604
- {
1605
- status: 202,
1606
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
1607
- }
1601
+ { status: 202 }
1608
1602
  );
1609
1603
  }
1610
1604
  return (0, import_core5.setApiResponse)(
@@ -1756,6 +1750,9 @@ function authController(store, config, bus) {
1756
1750
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
1757
1751
  },
1758
1752
  verify: async (ctx) => {
1753
+ if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
1754
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
1755
+ }
1759
1756
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
1760
1757
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "VERIFIED", "Email verified successfully.", {
1761
1758
  verified: true,
@@ -1842,6 +1839,9 @@ function authController(store, config, bus) {
1842
1839
  });
1843
1840
  },
1844
1841
  sendVerification: async (ctx) => {
1842
+ if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
1843
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
1844
+ }
1845
1845
  const resolved = { ...config, ...config.resolve?.(ctx) };
1846
1846
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
1847
1847
  if (ctx.user.loginMethod === "phone") {
@@ -2407,10 +2407,14 @@ function buildAuthRoutes(store, config, bus) {
2407
2407
  R("refresh", "POST", "/auth/refresh", (0, import_middlewares.validate)(refreshSchema), auth.refresh),
2408
2408
  // Email — Password Recovery (Public)
2409
2409
  R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), (0, import_middlewares.validate)(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
2410
- R("resetPassword", "POST", "/auth/email/reset", (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
2411
- // Verification (Protected — email or phone, determined by loginMethod)
2412
- R("verifyEmail", "POST", "/auth/verify", import_middlewares2.requireAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
2413
- R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAuth, auth.sendVerification),
2410
+ R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
2411
+ // Verification (email or phone, determined by loginMethod). requireAnyAuth,
2412
+ // not requireAuth: the phone-OTP flow reaches these with a short-lived
2413
+ // pending token (login/register no longer issue real tokens before the OTP
2414
+ // round-trip); the controllers reject pending tokens for any other flow.
2415
+ // ipLimit guards the 6-digit OTP against brute force (like mfaVerify).
2416
+ R("verifyEmail", "POST", "/auth/verify", ipLimit("verify"), import_middlewares2.requireAnyAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
2417
+ R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAnyAuth, auth.sendVerification),
2414
2418
  // Account Management (Protected)
2415
2419
  R("logout", "POST", "/auth/logout", import_middlewares2.requireAuth, (0, import_middlewares.validate)(refreshSchema), auth.logout),
2416
2420
  // User Profile (Protected; writes also gate on requireVerification)