@fonderie/auth 5.3.0 → 7.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
@@ -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' | 'reset' | 'mfaVerify';
206
+ type AuthLimitedRoute = 'login' | 'register' | 'forgot' | 'reset' | 'verify' | 'mfaVerify';
207
207
  ```
package/dist/index.cjs CHANGED
@@ -132,6 +132,9 @@ var DEFAULTS = {
132
132
  // so this IP bucket is the guard. (Moving reset to a high-entropy opaque
133
133
  // token is tracked as a follow-up hardening.)
134
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) } },
135
138
  // mfaVerify: 10/15min per IP — TOTP brute-force.
136
139
  mfaVerify: { ip: { capacity: 10, refillPerSec: 10 / min(15) } }
137
140
  };
@@ -1464,23 +1467,17 @@ function authController(store, config, bus) {
1464
1467
  reqOpts2
1465
1468
  ).catch(() => {
1466
1469
  });
1467
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1468
- loginMethod: "phone"
1469
- });
1470
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1470
+ const otpToken = issueMfaPendingToken(user.id, config, "phone");
1471
1471
  return Response.json(
1472
1472
  {
1473
1473
  reason: "USER_PHONE_REGISTERED",
1474
1474
  explanation: "Account created. A verification code has been sent to your phone.",
1475
1475
  result: {
1476
- tokens: { access: accessToken, refresh: refreshToken },
1476
+ otpToken,
1477
1477
  user: toUserDTO(user)
1478
1478
  }
1479
1479
  },
1480
- {
1481
- status: 202,
1482
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
1483
- }
1480
+ { status: 202 }
1484
1481
  );
1485
1482
  }
1486
1483
  return (0, import_core5.setApiResponse)(
@@ -1594,23 +1591,14 @@ function authController(store, config, bus) {
1594
1591
  recipient: { email: null, phone: normalizePhone(phone2), deviceToken: null }
1595
1592
  }).catch(() => {
1596
1593
  });
1597
- const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1598
- loginMethod: "phone"
1599
- });
1600
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
1594
+ const otpToken = issueMfaPendingToken(user.id, config, "phone");
1601
1595
  return Response.json(
1602
1596
  {
1603
1597
  reason: "USER_PHONE_OTP_SENT",
1604
1598
  explanation: "A verification code has been sent to your phone.",
1605
- result: {
1606
- tokens: { access: accessToken, refresh: refreshToken },
1607
- user: toUserDTO(user, false)
1608
- }
1599
+ result: { otpToken }
1609
1600
  },
1610
- {
1611
- status: 202,
1612
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
1613
- }
1601
+ { status: 202 }
1614
1602
  );
1615
1603
  }
1616
1604
  return (0, import_core5.setApiResponse)(
@@ -1756,12 +1744,18 @@ function authController(store, config, bus) {
1756
1744
  passwordHash,
1757
1745
  row.userId
1758
1746
  ]),
1759
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId])
1747
+ tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
1748
+ // Revoke every session: the reset exists because the account may
1749
+ // be compromised — a stolen session must not survive it.
1750
+ tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
1760
1751
  ]);
1761
1752
  });
1762
1753
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
1763
1754
  },
1764
1755
  verify: async (ctx) => {
1756
+ if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
1757
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
1758
+ }
1765
1759
  if (ctx.user.loginMethod !== "phone" && ctx.user.emailVerifiedAt) {
1766
1760
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "VERIFIED", "Email verified successfully.", {
1767
1761
  verified: true,
@@ -1848,6 +1842,9 @@ function authController(store, config, bus) {
1848
1842
  });
1849
1843
  },
1850
1844
  sendVerification: async (ctx) => {
1845
+ if (ctx.user.mfaPending && ctx.user.loginMethod !== "phone") {
1846
+ return (0, import_core5.setApiResponse)(import_core5.HTTP.FORBIDDEN, "MFA_REQUIRED", "Complete MFA verification to continue");
1847
+ }
1851
1848
  const resolved = { ...config, ...config.resolve?.(ctx) };
1852
1849
  const cooldown = resolved.verificationCooldown ?? DEFAULT_VERIFICATION_COOLDOWN;
1853
1850
  if (ctx.user.loginMethod === "phone") {
@@ -2271,6 +2268,7 @@ function userController(store, config, bus) {
2271
2268
  }
2272
2269
 
2273
2270
  // src/controllers/oauth.controller.ts
2271
+ var import_node_crypto6 = require("crypto");
2274
2272
  var import_core8 = require("@fonderie/core");
2275
2273
  function oauthController(store, config) {
2276
2274
  const users = new UserModel(store);
@@ -2286,18 +2284,30 @@ function oauthController(store, config) {
2286
2284
  "Google OAuth not configured"
2287
2285
  );
2288
2286
  }
2287
+ const state = (0, import_node_crypto6.randomBytes)(16).toString("hex");
2289
2288
  const params = new URLSearchParams({
2290
2289
  client_id: google.clientId,
2291
2290
  redirect_uri: google.redirectUri,
2292
2291
  response_type: "code",
2293
- scope: "openid email profile"
2292
+ scope: "openid email profile",
2293
+ state
2294
2294
  });
2295
2295
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2296
- return (0, import_core8.setApiResponse)(
2297
- import_core8.HTTP.OK,
2298
- "GOOGLE_AUTH_URL",
2299
- "Redirect the user to the returned URL to begin Google OAuth.",
2300
- { url }
2296
+ const secure = config.secureCookies ?? process.env["NODE_ENV"] === "production" ? "; Secure" : "";
2297
+ return Response.json(
2298
+ {
2299
+ reason: "GOOGLE_AUTH_URL",
2300
+ explanation: "Redirect the user to the returned URL to begin Google OAuth.",
2301
+ result: { url }
2302
+ },
2303
+ {
2304
+ status: 200,
2305
+ // SameSite=Lax (not Strict): the cookie must be sent on the
2306
+ // top-level cross-site redirect back from Google.
2307
+ headers: cookieHeaders([
2308
+ `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}`
2309
+ ])
2310
+ }
2301
2311
  );
2302
2312
  },
2303
2313
  googleCallback: async (ctx) => {
@@ -2314,6 +2324,16 @@ function oauthController(store, config) {
2314
2324
  if (!code) {
2315
2325
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2316
2326
  }
2327
+ const returnedState = url.searchParams.get("state") ?? "";
2328
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2329
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2330
+ if (!returnedState || !expectedState || !(0, import_core8.constantTimeEqual)(Buffer.from(returnedState), Buffer.from(expectedState))) {
2331
+ return (0, import_core8.setApiResponse)(
2332
+ import_core8.HTTP.BAD_REQUEST,
2333
+ "GOOGLE_AUTH_FAILED",
2334
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2335
+ );
2336
+ }
2317
2337
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2318
2338
  method: "POST",
2319
2339
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -2336,9 +2356,25 @@ function oauthController(store, config) {
2336
2356
  const payload = JSON.parse(
2337
2357
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2338
2358
  );
2359
+ if (payload.aud !== google.clientId) {
2360
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
2361
+ }
2362
+ if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
2363
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
2364
+ }
2365
+ if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
2366
+ return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
2367
+ }
2339
2368
  if (!payload.email) {
2340
2369
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2341
2370
  }
2371
+ if (payload.email_verified !== true) {
2372
+ return (0, import_core8.setApiResponse)(
2373
+ import_core8.HTTP.BAD_REQUEST,
2374
+ "GOOGLE_AUTH_FAILED",
2375
+ "Google account email is not verified"
2376
+ );
2377
+ }
2342
2378
  const normalizedEmail = normalizeEmailSafe(payload.email);
2343
2379
  if (!normalizedEmail) {
2344
2380
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
@@ -2382,7 +2418,11 @@ function oauthController(store, config) {
2382
2418
  },
2383
2419
  {
2384
2420
  status: 200,
2385
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
2421
+ headers: cookieHeaders([
2422
+ ...tokenPairCookies(accessToken, refreshToken, config),
2423
+ // One-time value — clear it once the flow completes.
2424
+ "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
2425
+ ])
2386
2426
  }
2387
2427
  );
2388
2428
  }
@@ -2414,9 +2454,13 @@ function buildAuthRoutes(store, config, bus) {
2414
2454
  // Email — Password Recovery (Public)
2415
2455
  R("forgotPassword", "POST", "/auth/email/forgot", ipLimit("forgot"), (0, import_middlewares.validate)(forgotPasswordSchema), acctLimit("forgot"), auth.forgotPassword),
2416
2456
  R("resetPassword", "POST", "/auth/email/reset", ipLimit("reset"), (0, import_middlewares.validate)(resetPasswordSchema), auth.resetPassword),
2417
- // Verification (Protected — email or phone, determined by loginMethod)
2418
- R("verifyEmail", "POST", "/auth/verify", import_middlewares2.requireAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
2419
- R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAuth, auth.sendVerification),
2457
+ // Verification (email or phone, determined by loginMethod). requireAnyAuth,
2458
+ // not requireAuth: the phone-OTP flow reaches these with a short-lived
2459
+ // pending token (login/register no longer issue real tokens before the OTP
2460
+ // round-trip); the controllers reject pending tokens for any other flow.
2461
+ // ipLimit guards the 6-digit OTP against brute force (like mfaVerify).
2462
+ R("verifyEmail", "POST", "/auth/verify", ipLimit("verify"), import_middlewares2.requireAnyAuth, (0, import_middlewares.validate)(verifySchema), auth.verify),
2463
+ R("sendVerification", "GET", "/auth/send-verification", import_middlewares2.requireAnyAuth, auth.sendVerification),
2420
2464
  // Account Management (Protected)
2421
2465
  R("logout", "POST", "/auth/logout", import_middlewares2.requireAuth, (0, import_middlewares.validate)(refreshSchema), auth.logout),
2422
2466
  // User Profile (Protected; writes also gate on requireVerification)