@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.js CHANGED
@@ -1704,7 +1704,10 @@ function authController(store, config, bus) {
1704
1704
  passwordHash,
1705
1705
  row.userId
1706
1706
  ]),
1707
- tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId])
1707
+ tx.query(`DELETE FROM fonderie_password_resets WHERE user_id = $1`, [row.userId]),
1708
+ // Revoke every session: the reset exists because the account may
1709
+ // be compromised — a stolen session must not survive it.
1710
+ tx.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [row.userId])
1708
1711
  ]);
1709
1712
  });
1710
1713
  return setApiResponse3(HTTP3.OK, "PASSWORD_RESET_SUCCESSFUL", "Password reset successfully.");
@@ -2225,7 +2228,8 @@ function userController(store, config, bus) {
2225
2228
  }
2226
2229
 
2227
2230
  // src/controllers/oauth.controller.ts
2228
- import { setApiResponse as setApiResponse5, HTTP as HTTP5 } from "@fonderie/core";
2231
+ import { randomBytes as randomBytes3 } from "crypto";
2232
+ import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
2229
2233
  function oauthController(store, config) {
2230
2234
  const users = new UserModel(store);
2231
2235
  const sessions = new SessionModel(store);
@@ -2240,18 +2244,30 @@ function oauthController(store, config) {
2240
2244
  "Google OAuth not configured"
2241
2245
  );
2242
2246
  }
2247
+ const state = randomBytes3(16).toString("hex");
2243
2248
  const params = new URLSearchParams({
2244
2249
  client_id: google.clientId,
2245
2250
  redirect_uri: google.redirectUri,
2246
2251
  response_type: "code",
2247
- scope: "openid email profile"
2252
+ scope: "openid email profile",
2253
+ state
2248
2254
  });
2249
2255
  const url = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
2250
- return setApiResponse5(
2251
- HTTP5.OK,
2252
- "GOOGLE_AUTH_URL",
2253
- "Redirect the user to the returned URL to begin Google OAuth.",
2254
- { url }
2256
+ const secure = config.secureCookies ?? process.env["NODE_ENV"] === "production" ? "; Secure" : "";
2257
+ return Response.json(
2258
+ {
2259
+ reason: "GOOGLE_AUTH_URL",
2260
+ explanation: "Redirect the user to the returned URL to begin Google OAuth.",
2261
+ result: { url }
2262
+ },
2263
+ {
2264
+ status: 200,
2265
+ // SameSite=Lax (not Strict): the cookie must be sent on the
2266
+ // top-level cross-site redirect back from Google.
2267
+ headers: cookieHeaders([
2268
+ `oauth_state=${state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=600${secure}`
2269
+ ])
2270
+ }
2255
2271
  );
2256
2272
  },
2257
2273
  googleCallback: async (ctx) => {
@@ -2268,6 +2284,16 @@ function oauthController(store, config) {
2268
2284
  if (!code) {
2269
2285
  return setApiResponse5(HTTP5.BAD_REQUEST, "INVALID_PARAMETER", "Missing code");
2270
2286
  }
2287
+ const returnedState = url.searchParams.get("state") ?? "";
2288
+ const cookieHeader = ctx.request.headers.get("cookie") ?? "";
2289
+ const expectedState = cookieHeader.match(/(?:^|;\s*)oauth_state=([^;]+)/)?.[1] ?? "";
2290
+ if (!returnedState || !expectedState || !constantTimeEqual2(Buffer.from(returnedState), Buffer.from(expectedState))) {
2291
+ return setApiResponse5(
2292
+ HTTP5.BAD_REQUEST,
2293
+ "GOOGLE_AUTH_FAILED",
2294
+ "OAuth state mismatch \u2014 restart the sign-in flow"
2295
+ );
2296
+ }
2271
2297
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
2272
2298
  method: "POST",
2273
2299
  headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -2290,9 +2316,25 @@ function oauthController(store, config) {
2290
2316
  const payload = JSON.parse(
2291
2317
  Buffer.from(tokenData.id_token.split(".")[1] ?? "", "base64url").toString()
2292
2318
  );
2319
+ if (payload.aud !== google.clientId) {
2320
+ return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token audience mismatch");
2321
+ }
2322
+ if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
2323
+ return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token issuer mismatch");
2324
+ }
2325
+ if (typeof payload.exp !== "number" || payload.exp * 1e3 < Date.now()) {
2326
+ return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "OAuth token expired");
2327
+ }
2293
2328
  if (!payload.email) {
2294
2329
  return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "No email in OAuth response");
2295
2330
  }
2331
+ if (payload.email_verified !== true) {
2332
+ return setApiResponse5(
2333
+ HTTP5.BAD_REQUEST,
2334
+ "GOOGLE_AUTH_FAILED",
2335
+ "Google account email is not verified"
2336
+ );
2337
+ }
2296
2338
  const normalizedEmail = normalizeEmailSafe(payload.email);
2297
2339
  if (!normalizedEmail) {
2298
2340
  return setApiResponse5(HTTP5.BAD_REQUEST, "GOOGLE_AUTH_FAILED", "Invalid email in OAuth response");
@@ -2336,7 +2378,11 @@ function oauthController(store, config) {
2336
2378
  },
2337
2379
  {
2338
2380
  status: 200,
2339
- headers: cookieHeaders(tokenPairCookies(accessToken, refreshToken, config))
2381
+ headers: cookieHeaders([
2382
+ ...tokenPairCookies(accessToken, refreshToken, config),
2383
+ // One-time value — clear it once the flow completes.
2384
+ "oauth_state=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
2385
+ ])
2340
2386
  }
2341
2387
  );
2342
2388
  }