@fonderie/auth 6.0.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/dist/index.cjs CHANGED
@@ -1744,7 +1744,10 @@ function authController(store, config, bus) {
1744
1744
  passwordHash,
1745
1745
  row.userId
1746
1746
  ]),
1747
- 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])
1748
1751
  ]);
1749
1752
  });
1750
1753
  return (0, import_core5.setApiResponse)(import_core5.HTTP.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
@@ -2265,6 +2268,7 @@ function userController(store, config, bus) {
2265
2268
  }
2266
2269
 
2267
2270
  // src/controllers/oauth.controller.ts
2271
+ var import_node_crypto6 = require("crypto");
2268
2272
  var import_core8 = require("@fonderie/core");
2269
2273
  function oauthController(store, config) {
2270
2274
  const users = new UserModel(store);
@@ -2280,18 +2284,30 @@ function oauthController(store, config) {
2280
2284
  "Google OAuth not configured"
2281
2285
  );
2282
2286
  }
2287
+ const state = (0, import_node_crypto6.randomBytes)(16).toString("hex");
2283
2288
  const params = new URLSearchParams({
2284
2289
  client_id: google.clientId,
2285
2290
  redirect_uri: google.redirectUri,
2286
2291
  response_type: "code",
2287
- scope: "openid email profile"
2292
+ scope: "openid email profile",
2293
+ state
2288
2294
  });
2289
2295
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2290
- return (0, import_core8.setApiResponse)(
2291
- import_core8.HTTP.OK,
2292
- "GOOGLE_AUTH_URL",
2293
- "Redirect the user to the returned URL to begin Google OAuth.",
2294
- { 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
+ }
2295
2311
  );
2296
2312
  },
2297
2313
  googleCallback: async (ctx) => {
@@ -2308,6 +2324,16 @@ function oauthController(store, config) {
2308
2324
  if (!code) {
2309
2325
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2310
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
+ }
2311
2337
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2312
2338
  method: "POST",
2313
2339
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -2330,9 +2356,25 @@ function oauthController(store, config) {
2330
2356
  const payload = JSON.parse(
2331
2357
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2332
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
+ }
2333
2368
  if (!payload.email) {
2334
2369
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2335
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
+ }
2336
2378
  const normalizedEmail = normalizeEmailSafe(payload.email);
2337
2379
  if (!normalizedEmail) {
2338
2380
  return (0, import_core8.setApiResponse)(import_core8.HTTP.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
@@ -2376,7 +2418,11 @@ function oauthController(store, config) {
2376
2418
  },
2377
2419
  {
2378
2420
  status: 200,
2379
- 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
+ ])
2380
2426
  }
2381
2427
  );
2382
2428
  }